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    /// bsv-low#302: per-peer GASP sync wall-clock budget —
80    /// `(sleep factory, budget ms)`. `None` (default) = unbounded per-peer
81    /// sync, byte-identical to the pre-#302 behavior. When set, each peer's
82    /// `GASPSync::sync` is raced against `sleep(budget_ms)`; a peer that
83    /// exceeds the budget is DROPPED (loud log, failure recorded, cursor NOT
84    /// advanced) and the loop continues with the next peer.
85    peer_sync_budget: Option<(SleepFactory, u64)>,
86    /// The per-GRAPH GASP budget (bsv-low #555): the sleep, the calls, the
87    /// ms. `None`: no graph is deferred.
88    graph_budget: Option<(SleepFactory, u32, u64)>,
89    /// The per-graph budget's bytes and nodes limbs (bsv-low #586,
90    /// [`Engine::set_graph_budget_limbs`]): in force once a graph budget is
91    /// set.
92    graph_budget_limbs: (u64, u32),
93    /// The bound of one transaction's finalize submit (bsv-low #559), see
94    /// [`Engine::set_finalize_submit_budget`].
95    finalize_submit_budget: Option<(SleepFactory, u64)>,
96    /// Open around each GASP finalize submit, so no deadline that races the
97    /// sync drops a transaction between its writes (bsv-low #552, the lens
98    /// fold's HIGH-1). See [`Engine::finalize_submit_gate`].
99    finalize_gate: crate::gasp::SubmitGate,
100    /// (txid, topic) pairs whose Phase-3 writes FAULTED in this engine and
101    /// have not landed since (bsv-low #559): a successor that finds no coin
102    /// while its predecessor is in here is not recorded as applied. One
103    /// invocation's memory; across invocations the same question is put to
104    /// the store ([`Engine::unlanded_predecessor`]).
105    not_landed: std::cell::RefCell<HashSet<(String, String)>>,
106    /// Asked before the door lands a carried predecessor (bsv-low #575, the
107    /// E1D delta-2 fold, L2). See [`Engine::set_landing_guard`].
108    landing_guard: Option<LandingGuard>,
109    /// Reference-parity spend verification on submit (2026-09-08). `true`
110    /// (DEFAULT): every submit outside `HistoricalTxNoSpv` runs the
111    /// reference's `Transaction.verify` walk: merkle paths against the chain
112    /// tracker AND every input's unlocking script EXECUTED, recursively over
113    /// the unproven ancestry in the BEEF; with no chain tracker the walk is
114    /// the ts-sdk's `'scripts only'` (roots accepted unchecked, scripts still
115    /// run). `false` is an ESCAPE HATCH, not a mode: it restores the
116    /// pre-2026-09-08 structural check (`Beef::verify_valid` + every root
117    /// against the tracker; NOTHING without a tracker), which admitted an
118    /// invalid spend on structure alone. See
119    /// [`Engine::set_script_verification`].
120    verify_scripts: bool,
121    /// The two limbs of the submit's own script walk (bsv-low #592): the
122    /// door's [`DoorBudget`], [`DoorBudget::DEFAULT`] unless set. See
123    /// [`Engine::set_walk_budget`].
124    walk_budget: DoorBudget,
125    config: EngineConfig,
126}
127
128/// Boxed platform sleep future (bsv-low#302). Not `Send` — the engine runs
129/// single-threaded on wasm (Workers) and under `#[tokio::test]` locally.
130pub type SleepFuture = std::pin::Pin<Box<dyn std::future::Future<Output = ()>>>;
131
132/// Platform sleep factory (bsv-low#302): milliseconds → a future that
133/// resolves after that delay. The Cloudflare crate passes its `sleep_ms`;
134/// tests pass `ready(())` (instant deadline) or `pending()` (no deadline)
135/// to drive the race deterministically.
136pub type SleepFactory = std::rc::Rc<dyn Fn(u64) -> SleepFuture>;
137
138/// The answer of a [`LandingGuard`]: `Ok` lets the body land, `Err` says why
139/// it may not (the topic waits).
140pub type LandingGuardFuture =
141    std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
142
143/// The caller's admission predicate over a carried predecessor the door is
144/// about to land: `(predecessor txid, topic)`. See
145/// [`Engine::set_landing_guard`].
146pub type LandingGuard = std::rc::Rc<dyn Fn(&str, &str) -> LandingGuardFuture>;
147
148/// The bound of ONE GASP finalize submit's storage calls and hooks (bsv-low
149/// #559, the lens fold of 2026-10-07, F2). The submit itself is never
150/// dropped: it is the write section no deadline drops, and c9921ee, which
151/// raced the whole submit against its budget inside the section, dropped it
152/// between two writes all the same. Each storage call, lookup hook and topic
153/// manager call inside it is raced against the section's ONE deadline
154/// instead, and a call that has not answered when the deadline falls due is
155/// dropped ALONE and reported as that call's fault: the submit runs on to
156/// its end as for any storage fault. Once the deadline is due no further
157/// call is started (each answers "no answer" at once), so a transaction
158/// costs at most its budget, and once more for the undo
159/// ([`CallBound::renew`]).
160struct CallBound {
161    sleep: SleepFactory,
162    budget_ms: u64,
163    deadline: std::cell::RefCell<SleepFuture>,
164    due: std::cell::Cell<bool>,
165}
166
167impl CallBound {
168    fn new(sleep: &SleepFactory, budget_ms: u64) -> Self {
169        Self {
170            deadline: std::cell::RefCell::new(sleep(budget_ms)),
171            sleep: sleep.clone(),
172            budget_ms,
173            due: std::cell::Cell::new(false),
174        }
175    }
176
177    /// A fresh allowance of the same length, for the undo of a faulted
178    /// submit: the calls that put the store back as it was found must not be
179    /// refused because the deadline the fault answered to has passed.
180    fn renew(&self) {
181        *self.deadline.borrow_mut() = (self.sleep)(self.budget_ms);
182        self.due.set(false);
183    }
184
185    /// Whether a call under this bound went unanswered (or was not started
186    /// because one had) since the last [`CallBound::renew`].
187    fn is_due(&self) -> bool {
188        self.due.get()
189    }
190
191    /// `call`, or `None` when the deadline fell due first.
192    async fn call<T>(&self, call: impl std::future::Future<Output = T>) -> Option<T> {
193        if self.due.get() {
194            return None;
195        }
196        let mut call = std::pin::pin!(call);
197        std::future::poll_fn(|cx| {
198            if let std::task::Poll::Ready(v) = call.as_mut().poll(cx) {
199                return std::task::Poll::Ready(Some(v));
200            }
201            if self.deadline.borrow_mut().as_mut().poll(cx).is_ready() {
202                self.due.set(true);
203                return std::task::Poll::Ready(None);
204            }
205            std::task::Poll::Pending
206        })
207        .await
208    }
209}
210
211/// `call` under `bound` (or unbounded with none); `Err` is "no answer".
212async fn bounded<T>(
213    bound: Option<&CallBound>,
214    call: impl std::future::Future<Output = T>,
215) -> Result<T, String> {
216    match bound {
217        Some(bound) => bound
218            .call(call)
219            .await
220            .ok_or_else(|| format!("no answer within {} ms", bound.budget_ms)),
221        None => Ok(call.await),
222    }
223}
224
225/// A storage call under `bound`: no answer in time is that call's fault.
226async fn stored<T>(
227    bound: Option<&CallBound>,
228    call: impl std::future::Future<Output = Result<T, StorageError>>,
229) -> Result<T, StorageError> {
230    match bounded(bound, call).await {
231        Ok(answer) => answer,
232        Err(no_answer) => Err(StorageError::Database(no_answer)),
233    }
234}
235
236/// A lookup hook under `bound`, its error as the report carries it.
237async fn hooked(
238    bound: Option<&CallBound>,
239    call: impl std::future::Future<Output = Result<(), LookupServiceError>>,
240) -> Result<(), String> {
241    bounded(bound, call).await?.map_err(|e| e.to_string())
242}
243
244/// The most store reads [`Engine::unlanded_predecessor`] makes for one
245/// topic of one submit (bsv-low #559, F3): the question is asked of every
246/// transaction that admits nothing and found no coin, so it is kept cheap.
247/// Counted are the reads spent on a body the BEEF does not prove and the
248/// store does not hold as landed (the second delta fold of 2026-10-07, M2);
249/// the 17th of those is never made: out of reads is "not now" (the delta
250/// fold of 2026-10-07, M2). An unproven body still needs room under it for
251/// the reads that find it landed (one for an applied row, two for a held
252/// output): each is tested before it is made.
253const PREDECESSOR_READS: usize = 16;
254
255/// The most store reads [`Engine::unlanded_predecessor`] makes for one
256/// SUBMIT (the third delta fold of 2026-10-07, M1): every read of the
257/// question, counted against [`PREDECESSOR_READS`] or not (those of proven
258/// and landed bodies included), in every topic of the submit together. The
259/// 257th is never made: past the allowance the answer is "not now", never
260/// "landed". Without it the question read as far as the BEEF reached (one
261/// read per 41 bytes: 66,305 reads measured from one 2.7 MB BEEF), on a
262/// public route. Per submit and not per topic because what it protects, the
263/// invocation's D1 allowance, is per invocation; 256 is the door's own unit
264/// ([`DoorBudget`]'s inputs per transaction), no derived figure.
265const PREDECESSOR_READS_PER_SUBMIT: usize = 256;
266
267/// Why a successor must wait ([`Engine::unlanded_predecessor`]), and the
268/// unlanded predecessor whose body the BEEF carries, when the answer names
269/// one: the door lands it first ([`Engine::land_carried`]).
270/// A submit's STEAK and report, and the carried predecessor that blocks each
271/// topic of a carried predecessor's own submit ([`Engine::submit_counted`]).
272type Counted = (Steak, MutationReport, HashMap<String, String>);
273
274/// What the landings of carried predecessors take from their successor's
275/// submit (the E592 lens fold, M1): its breach policy, and whether its own
276/// walk RAN (only then are the bodies it reached trusted).
277#[derive(Debug, Clone, Copy)]
278struct Landing {
279    policy: WalkBreachPolicy,
280    subject_walk_ran: bool,
281}
282
283struct NotNow {
284    why: String,
285    carried: Option<String>,
286}
287
288/// Summary of an [`Engine::complete_missing_proofs`] pass.
289///
290/// All counts are over the single bounded page scanned this tick. A no-fetcher
291/// (production) build returns the all-zero default.
292#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
293pub struct ProofCompletionSummary {
294    /// Stored transactions inspected this tick (the bounded page size).
295    pub scanned: usize,
296    /// Of `scanned`, how many still lacked a merkle proof for their own tx.
297    pub proofless: usize,
298    /// Proofless txs whose BUMP was fetched + stitched into the stored BEEF.
299    pub completed: usize,
300    /// Proofless txs the fetcher could serve but that are not yet mined
301    /// (no BUMP yet) — retried on a later tick.
302    pub still_unconfirmed: usize,
303    /// Proofless txs the fetcher errored on (e.g. budget exhausted, 429) —
304    /// retried on a later tick.
305    pub fetch_failed: usize,
306    /// Proofless txs whose proof came back but failed to stitch into storage.
307    pub stitch_failed: usize,
308    /// Scanned rows that ALREADY carried a valid proof (stale `has_proof = 0`
309    /// flag) and were marked proven this tick so they drop out of the candidate
310    /// window — clearing the window-clog (#130).
311    pub already_proven: usize,
312}
313
314/// Outcome of one [`Engine::sync_advertisements`] run (bsv-low #320 defect
315/// 3a).
316///
317/// The engine used to swallow create/submit failures into `error!` and
318/// return `Ok(())`, so the admin route reported `success` while zero
319/// advertisements were admitted locally; a caller could not tell a
320/// converged no-op (`to_create == 0`) from a silent failure. Every field
321/// here is observational — the sync's behavior is unchanged, its outcome is
322/// no longer hidden.
323#[derive(Debug, Default, Clone, Serialize)]
324#[serde(rename_all = "camelCase")]
325pub struct SyncAdvertisementsReport {
326    /// Advertisements the diff found missing (attempted this run). A
327    /// converged node reports 0 here.
328    pub to_create: usize,
329    /// Stale advertisements the diff said to revoke.
330    pub to_revoke: usize,
331    /// `advertiser.create_advertisements` failure, verbatim.
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub create_error: Option<String>,
334    /// Local `Engine::submit` failure for the created ads, verbatim — the
335    /// path that silently hid the self-admission failure live (our own
336    /// ls_ship/ls_slap never gained our ads, so every cycle re-created all
337    /// of them).
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub submit_error: Option<String>,
340    /// Outputs admitted per topic by the local submit (from the STEAK).
341    /// A successful submit that admitted NOTHING (topic-manager refusal)
342    /// shows up here as explicit zeros.
343    pub admitted: std::collections::BTreeMap<String, usize>,
344    /// `advertiser.find_all_advertisements` failure, verbatim (#320 M2).
345    /// When set, creation was REFUSED this run: a blind read must never
346    /// become "current ads = none" and re-create (re-pay) the entire set.
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub lookup_error: Option<String>,
349    /// Stale ads the diff wanted revoked but the advertiser declined to
350    /// build a revocation for (the CF advertiser's documented v1 no-op —
351    /// #320 L2). Distinguishes "revoke happened" from "revoke skipped".
352    pub revoke_skipped: usize,
353    /// `advertiser.revoke_advertisements` failure, verbatim.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub revoke_error: Option<String>,
356    /// Local `Engine::submit` failure for the revocation, verbatim.
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub revoke_submit_error: Option<String>,
359}
360
361impl SyncAdvertisementsReport {
362    /// True when nothing errored. NOTE: a submit that admitted zero outputs
363    /// is not an error at this layer — that refusal is [`Self::effective`]'s
364    /// job.
365    #[must_use]
366    pub fn ok(&self) -> bool {
367        self.create_error.is_none()
368            && self.submit_error.is_none()
369            && self.lookup_error.is_none()
370            && self.revoke_error.is_none()
371            && self.revoke_submit_error.is_none()
372    }
373
374    /// Total outputs admitted by the local submit across topics.
375    #[must_use]
376    pub fn admitted_total(&self) -> usize {
377        self.admitted.values().sum()
378    }
379
380    /// True when the run errored nowhere AND the local submit admitted
381    /// EVERY created ad (#320 M1 + delta D1): each advertisement is exactly
382    /// one output and change is never a SHIP/SLAP token, so full admission
383    /// means `admitted_total() >= to_create` (defensive `>=`). A weaker
384    /// `> 0` bar let a PARTIAL admit pass — the live set is 9 SHIP + 8 SLAP
385    /// in ONE tx with independent per-topic admission, so tm_ship admitting
386    /// its 9 while tm_slap refused all 8 read as success while the diff
387    /// re-created (re-paid for) the refused 8 every cycle. Subsumes the
388    /// converged no-op (`0 >= 0`).
389    #[must_use]
390    pub fn effective(&self) -> bool {
391        self.ok() && self.admitted_total() >= self.to_create
392    }
393}
394
395/// Internal result from Phase 1 + 2 validation.
396///
397/// Carries per-topic admittance decisions, previous coins, and the parsed
398/// transaction so that Phase 3 (mutations) can proceed without re-parsing.
399struct TopicValidation {
400    topic: String,
401    is_dupe: bool,
402    /// Input indices that spend previously-admitted outputs from this topic.
403    previous_coins: Vec<u32>,
404    /// The previous outputs found in storage (parallel to previous_coins).
405    previous_outputs: Vec<Output>,
406    admittance: AdmittanceInstructions,
407    failed: bool,
408    /// The FIRST storage read fault hit while scanning this topic's
409    /// previous outputs (S2, bsv-low 2026-08-29). A faulted read makes a
410    /// spend look like it consumes nothing — the settle's spend pointer is
411    /// then never written — so it is carried into the [`MutationReport`]
412    /// and blocks the `applied_transactions` record for this topic: a
413    /// replay re-reads instead of being deduplicated away. With the read
414    /// that faulted: `find_output` (a previous coin) or
415    /// `does_applied_transaction_exist` (the dedup read, the delta fold of
416    /// 2026-10-07, H3).
417    read_fault: Option<(&'static str, String)>,
418}
419
420/// One Phase-3 write — or the validation read it depends on — that FAILED
421/// during a submit (S2 queue-durable admission, bsv-low 2026-08-29).
422///
423/// Before this existed every such failure was `error!`-logged and swallowed
424/// while `submit` returned `Ok(steak)`: under a D1-overload storm the
425/// overlay ACKED admissions whose writes never landed (the 2026-08-26
426/// phantom class — `c4d2ed06…`/`62c2a9b3…` absent from the engine store,
427/// beta D1 queried directly). A fault is now a VALUE the caller can act on
428/// (re-queue for an idempotent replay) instead of a log line nobody reads.
429#[derive(Debug, Clone, PartialEq, Eq)]
430pub struct MutationFault {
431    /// The topic whose mutation faulted.
432    pub topic: String,
433    /// The write (or read) that failed: `insert_output`,
434    /// `mark_utxo_as_spent`, `update_consumed_by`, `delete_utxo_deep`,
435    /// `insert_applied_transaction`, `find_output` (validation read),
436    /// `does_applied_transaction_exist` (the dedup read),
437    /// `lookup_service.output_spent`, `lookup_service.output_admitted_by_topic`,
438    /// `undo_insert_output` (the undo of a faulted submit's inserts),
439    /// `record_spent_coin_applied` (the applied row a spender writes for the
440    /// transaction of a coin it deletes), `predecessor_not_landed`.
441    pub site: &'static str,
442    /// The backend's own error text.
443    pub error: String,
444}
445
446/// What Phase 3 actually landed for a submit — the durability report.
447///
448/// `applied_topics` are the topics whose EVERY write (and lookup-service
449/// notification) succeeded and were therefore recorded in
450/// `applied_transactions`. A topic with any fault is deliberately NOT
451/// recorded: the dedup in Phase 1 would otherwise turn a replay into a
452/// no-op and make the loss permanent (the ordering was already
453/// write-then-record; the record is now conditional on the writes).
454#[derive(Debug, Default, Clone, PartialEq, Eq)]
455pub struct MutationReport {
456    pub faults: Vec<MutationFault>,
457    pub applied_topics: Vec<String>,
458    /// Topics this (txid, topic) pair had ALREADY been applied under — the
459    /// Phase-1 dedup skipped them, so their STEAK entry is an empty default
460    /// that means "nothing new", never "judged another tx" (loop-3 client
461    /// belt re-presents read the empty STEAK as index-pending).
462    pub deduped_topics: Vec<String>,
463    /// The predecessors the door LANDED first from this submit's BEEF, as
464    /// `(txid, topic)`, ancestors first ([`Engine::land_carried`]; the E1D
465    /// delta fold, L2). Each is an admission write of its own that no
466    /// caller asked for: a caller that guards its admission writes (the
467    /// worker's eviction ledger, bsv-low #513) guards these too.
468    pub landed_predecessors: Vec<(String, String)>,
469    /// The submit's own script walk could not run within the engine's
470    /// budget (bsv-low #592) and the submit went on as under
471    /// `HistoricalTxNoSpv` ([`WalkBreachPolicy::NetworkAccepted`] only; under
472    /// `NotNow` a breach is [`EngineError::WalkCouldNotRun`]). `None` when it
473    /// ran (or the mode does not walk). A caller counts it; it never makes a
474    /// report undurable.
475    pub walk_could_not_run: Option<WalkCouldNotRun>,
476    /// The walks of the carried predecessors this submit tried to land
477    /// ([`Engine::land_carried`]) that could not run within the budget (the
478    /// E592 lens fold, L3): under `NotNow` each one's landing did not happen
479    /// (the successor waits), under `NetworkAccepted` each went on. Counted
480    /// by the caller beside `walk_could_not_run`.
481    pub landed_walks_could_not_run: Vec<WalkCouldNotRun>,
482}
483
484/// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): what [`Engine::renotify_admitted`] did.
485#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
486pub struct RenotifyReport {
487    /// stored outputs of (txid, topic) the engine had ALREADY admitted
488    pub outputs: u32,
489    /// lookup-service notifications that returned Ok (outputs × services)
490    pub notified: u32,
491    /// notifications that failed — `<service>: <error>`; the others still ran
492    pub faults: Vec<String>,
493    /// the output indexes re-notified (the caller's spend discovery keys on them)
494    pub vouts: Vec<u32>,
495}
496
497impl MutationReport {
498    fn fault(&mut self, topic: &str, site: &'static str, error: String) {
499        self.faults.push(MutationFault {
500            topic: topic.to_string(),
501            site,
502            error,
503        });
504    }
505
506    /// True when every mutation this submit needed landed (nothing to
507    /// replay). A dupe-only submit is durable (there was nothing to write).
508    #[must_use]
509    pub fn is_durable(&self) -> bool {
510        self.faults.is_empty()
511    }
512
513    /// One log line naming every fault (`topic/site: error`), each error
514    /// clipped so a D1 stack dump cannot flood the log stream.
515    #[must_use]
516    pub fn summary(&self) -> String {
517        self.faults
518            .iter()
519            .map(|f| {
520                let err: String = f.error.chars().take(120).collect();
521                format!("{}/{}: {}", f.topic, f.site, err)
522            })
523            .collect::<Vec<_>>()
524            .join("; ")
525    }
526}
527
528/// The DOOR's static work bound (bsv-low W-A gate MED-1, 2026-09-09). Bitcoin
529/// script has no loops: every opcode runs at most once, so an input's work is
530/// bounded BEFORE execution by its opcode census times the interpreter's
531/// element limit (the stack memory limit bounds every element, and every
532/// CAT / NUM2BIN / SPLIT result with it) plus its signature checks times the
533/// transaction's own size or a floor, whichever is more (a BIP-143 preimage
534/// hashes the prevouts and the outputs; the EC verification costs the same
535/// whatever the transaction weighs). The estimate is computed from the bytes;
536/// nothing executes past the budget, and a budget breach is the DOOR's
537/// verdict (inconclusive: the network judges), never the interpreter's.
538///
539/// THE BODY IS NOT COUNTED (bsv-low #585, door 1). Until #585 the budget also
540/// counted the BODY: 64 unproven transactions, 256 inputs per transaction,
541/// 512 KB per transaction, and the parse's own size and counts. A valid BEEF
542/// is never left unjudged for its size or its counts, so those are gone: the
543/// walk reads the body through the streaming reader ([`crate::script_door`]).
544///
545/// TWO LIMBS, both a budget whose breach is "the network judges" and never a
546/// refusal: the WORK the interpreter will do, and the MEMORY the door will
547/// hold beside the caller's body. Each is estimated from the bytes before it
548/// is spent.
549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub struct DoorBudget {
551    /// The interpreter's stack memory limit: bounds every element, and is the
552    /// unit of the work estimate (a hash-class opcode is charged one element).
553    /// Not a bound on the BEEF: a script that trips it is "the network
554    /// judges", as a breach of the work is.
555    pub memory_limit: usize,
556    /// The whole walk's estimated work, in bytes hashed or pushed.
557    pub max_work_bytes: u64,
558    /// The least a signature check is charged (the doors lens E585-D12-M1).
559    /// A check is charged `max(its transaction's bytes, this)`: the digest
560    /// hashes about the transaction, and the EC verification behind it costs
561    /// the same whatever the transaction weighs (about 95 us native). Charged
562    /// the transaction's bytes alone, a 106 byte `OP_CHECKSIG OP_NOT`
563    /// transaction bought a verification for 106 bytes of budget: 90,000 of
564    /// them walked at a fifth of the budget in 9.6 s. With the floor the
565    /// budget bounds the EC verifications of one walk at
566    /// `max_work_bytes / sig_check_floor`.
567    pub sig_check_floor: u64,
568    /// The whole walk's estimated memory beside the body, in bytes (the doors
569    /// lens E585-D12-L3): what the index, the stream's element in hand and
570    /// the walk's layout of a transaction hold, estimated from the frame's
571    /// lengths and counts by [`crate::stream_sizing::estimate`] BEFORE the
572    /// stream is opened; and, per input, what its two scripts hold once
573    /// parsed and run (the delta lens E585-D12-DELTA-M1: bsv-rs's chunk
574    /// records, their clones, a signature's subscript, the empty stack
575    /// entries the interpreter's memory limit does not count), charged from
576    /// the scripts' chunk count and bytes BEFORE either is parsed
577    /// (`script_door::script_parse_charge`, 512 bytes a chunk and 8 a byte).
578    ///
579    /// How the two compose: the frame's estimate is the most the walk holds
580    /// at any moment apart from the input being run (what is kept, and the
581    /// heaviest transaction in hand, counted once with its raw bytes and the
582    /// interpreter's raw copies of the unlocking script); the scripts' charge
583    /// is the EXPANSION of one input's scripts past those bytes, held while
584    /// that input runs and released after it. An input is run only while the
585    /// frame's estimate plus its own charge is within this limb.
586    pub max_memory_bytes: u64,
587}
588
589impl DoorBudget {
590    /// Sized for LOW's real shapes with a wide margin. What the static census
591    /// BOUNDS: hashing (hash-class ops × the element limit) and signature
592    /// checks (× the transaction's size or the floor, whichever is more; a
593    /// CHECKMULTISIG weighted by its static key count when the count is a
594    /// small-int push, else by the most keys the element limit admits).
595    ///
596    /// THE FLOOR, 64 KiB (half the hash charge), chosen against LOW's shapes
597    /// as the pin `door_low_shapes_sit_under_a_quarter_of_the_work_budget`
598    /// measures them (work, of the 64 MiB; memory, the frame's and the
599    /// largest input's scripts, of the 48 MiB limb; work before the floor in
600    /// brackets):
601    ///
602    /// | shape | inputs | work | memory |
603    /// |---|---|---|---|
604    /// | 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 % |
605    /// | the real covenant refund | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
606    /// | 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 % |
607    /// | STRESS, not LOW's shape: nine seats, three hops each | 36 | 7,082,619, 10.6 % (4,740,838) | 36,420, 0.07 % |
608    /// | a coin 30 P2PKH hops deep | 30 | 5,902,188, 8.8 % (3,941,856) | 23,720, 0.05 % |
609    ///
610    /// A P2PKH input is charged 196,739 bytes (a hash opcode at
611    /// the element limit, a check at the floor, its 131 script bytes): 341 of
612    /// them are the budget, 85 a quarter of it. The bound on EC verifications
613    /// per walk is 64 MiB / 64 KiB = 1,024 (1,023 with their script bytes):
614    /// measured natively, 1,000 of them walk in 87 ms in the release profile
615    /// (1.4 to 1.8 s in a debug build), and the lens's 17,000 stop at the
616    /// 1,024th in 108 ms. wasm32 was not measured.
617    ///
618    /// THE MEMORY, 48 MiB: three eighths of a 128 MB isolate. Beside it stand,
619    /// for a 10 MB request (the figure the arithmetic is made at: since NL-6
620    /// the route caps no body, so a heavier one is the isolate's), the
621    /// request's body, the completed BEEF the route hands the door (a second
622    /// copy of up to as much) and the EF batch (the route's 2 MB bound, twice
623    /// while it is serialized): 24 MB, which leaves 56 MB for the module, the
624    /// runtime, the allocator's fragmentation and what a native estimate does
625    /// not see of wasm32. The limb bounds the walk (the door's, and since
626    /// bsv-low #592 the submit's own and the GASP anchor's under
627    /// [`Engine::set_walk_budget`]) and nothing else: the route's own
628    /// hydrated parses before it are bounded by the isolate only (the land
629    /// lens E585-LAND-M1: 1.43 GB natively for a 9.9 MB body of 900,000
630    /// minimal transactions, before the route's 429). What
631    /// it lets through, by the door's charges (`script_door::DOOR_CHARGES`,
632    /// each an upper bound: the pins measure 1.3 to 5.3 times the heap the
633    /// walk reaches): a BUMP of up to 72,944 leaves (690 bytes a level-0
634    /// leaf); a single transaction of up to about 12 MB (4 bytes a byte)
635    /// whose walked inputs' scripts are few chunks (a script is charged 520
636    /// bytes a one-byte opcode and 8 a pushed byte: an input whose two
637    /// scripts are about 96,000 opcodes, or a 1 MB `OP_NOP` lock, is past
638    /// the limb before it is parsed, the network judges; LOW's covenant leg,
639    /// 6,608 script bytes, is charged 1.3 MB); a 10 MB body of one-input,
640    /// one-output P2PKH transactions (191 bytes, charged 570: 30 MB). What it
641    /// stops: 111,848 transactions of no input and no output, whatever
642    /// follows them.
643    ///
644    /// THE OPEN RESIDUAL (the W-A gate's delta-verify, 2026-09-09, measured):
645    /// the interpreter itself meters nothing, so two classes stay charged
646    /// only by their script bytes — bignum arithmetic (`OP_MUL`/`OP_DIV`/
647    /// `OP_MOD` on operands up to the element limit: ~1.3 ms per 30 KB×30 KB
648    /// round, linear in rounds) and `OP_NUM2BIN`, which
649    /// allocates its size operand BEFORE the memory limit is consulted (up to
650    /// bsv-rs's 1 GB element size: an isolate kill, not a refusal). None of
651    /// them can refuse a spend (a breach and a trip are the door's bound and
652    /// the request proceeds); all of them cost the beta overlay CPU until
653    /// bsv-rs meters work dynamically (a `work_limit` charged per op with the
654    /// real operand sizes, and a pre-allocation check in NUM2BIN) — OWED
655    /// before any PROD flip of `SCRIPT_VERIFY_NETWORK_GATED`. Since #585 the
656    /// count of such rounds is bounded by the body alone (the script bytes
657    /// are charged, the transactions and inputs are not counted). A third,
658    /// as before #585 (the doors lens N7): an opcode that copies an element
659    /// (`OP_DUP`, `OP_CAT`, `OP_PICK`) is charged its one script byte and can
660    /// copy up to the element limit. The class the floor CLOSED: a
661    /// CHECKMULTISIG whose key count is a computed value was charged the most
662    /// keys the element limit admits (3,971) at its transaction's bytes and
663    /// run (~80 µs per key tried); at the floor those are 260 MB, past the
664    /// budget, so such an input is never run at the door (the network
665    /// judges). LOW's covenant states its count (`OP_3`).
666    pub const DEFAULT: DoorBudget = DoorBudget {
667        memory_limit: 128 * 1024,
668        max_work_bytes: 64 * 1024 * 1024,
669        sig_check_floor: 64 * 1024,
670        max_memory_bytes: 48 * 1024 * 1024,
671    };
672}
673
674/// Which limb of the [`DoorBudget`] a walk went past.
675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
676pub enum DoorLimb {
677    /// The static work estimate, or the interpreter's own memory limit
678    /// tripping inside one input.
679    Work,
680    /// The estimate of what the door holds beside the body.
681    Memory,
682}
683
684/// What [`Engine::run_validation`] answers: the topics' validations, the
685/// preliminary STEAK, the subject and its txid, and whether the walk could
686/// not run within the budget (bsv-low #592).
687type Validated = (
688    Vec<TopicValidation>,
689    Steak,
690    Transaction,
691    String,
692    Option<WalkCouldNotRun>,
693);
694
695/// Which limb of the [`DoorBudget`] a submit's own walk went past (bsv-low
696/// #592): the [`DoorLimb`] of the door's error, with the interpreter's own
697/// memory limit told apart from the static work estimate.
698#[derive(Debug, Clone, Copy, PartialEq, Eq)]
699pub enum WalkLimb {
700    /// The static work estimate.
701    OverWork,
702    /// The estimate of what the walk holds beside the body.
703    OverMemory,
704    /// The interpreter's own memory limit (the budget's element limit)
705    /// tripping inside one input.
706    InterpreterMemory,
707}
708
709impl WalkLimb {
710    /// The door's word for the same limb ([`DoorLimb`]: the interpreter's
711    /// trip is `Work` there).
712    #[must_use]
713    pub fn door_limb(self) -> DoorLimb {
714        match self {
715            WalkLimb::OverWork | WalkLimb::InterpreterMemory => DoorLimb::Work,
716            WalkLimb::OverMemory => DoorLimb::Memory,
717        }
718    }
719
720    /// The limb's name in a log line and a counter's reason.
721    #[must_use]
722    pub fn as_str(self) -> &'static str {
723        match self {
724            WalkLimb::OverWork => "over_work",
725            WalkLimb::OverMemory => "over_memory",
726            WalkLimb::InterpreterMemory => "interpreter_memory",
727        }
728    }
729}
730
731/// What a submit does when its own script walk could not run within the
732/// engine's [`DoorBudget`] (bsv-low #592; the E592 lens fold, H1, the owner's
733/// ruling 3a read with the lens): a breach is NEVER an admission without the
734/// network's word on record.
735///
736/// The work limb is a static estimate from the bytes, and a LOCK can be made
737/// to pass it cheaply (a bare `OP_CHECKMULTISIG` in a fabricated parent's
738/// lock is charged 3,971 keys). So a breach says nothing about whether the
739/// spend is valid, and on a path where nothing else judges it an admission
740/// would admit a spend nobody checked: the lens's forged spend of a held coin.
741#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
742pub enum WalkBreachPolicy {
743    /// "Not now" ([`EngineError::WalkCouldNotRun`]): nothing judged, nothing
744    /// written, never a final refusal. Every door but one: the ungated
745    /// modes, `/admin/readmit`, the peer crawler, a library consumer's
746    /// submit; the GASP anchor HOLDS its UTXO the same way.
747    #[default]
748    NotNow,
749    /// The network's ACCEPT of these bytes is on record (the queue's replay
750    /// of a gated submission, whose producer broadcast them and marked the
751    /// message): the submit goes on exactly as under `HistoricalTxNoSpv`,
752    /// reported on [`MutationReport::walk_could_not_run`]. The roots the walk
753    /// reached are still checked, and a bad one is still the `SpvError` it is.
754    NetworkAccepted,
755}
756
757/// A submit whose own script walk COULD NOT RUN within the engine's
758/// [`DoorBudget`] (bsv-low #592, the owner's ruling 3a of 2026-10-10). Shaped
759/// like [`EngineError::ScriptWalkOverBudget`]. Under
760/// [`WalkBreachPolicy::NotNow`] it is carried by
761/// [`EngineError::WalkCouldNotRun`] (not now); under
762/// [`WalkBreachPolicy::NetworkAccepted`] the submit went on as under
763/// `HistoricalTxNoSpv` and this says so on its [`MutationReport`]. The
764/// reference (`Engine.submit`, `tx.verify`) has no budget; this is the
765/// platform's addition, on the door's lineage (D8).
766#[derive(Debug, Clone, PartialEq, Eq)]
767pub struct WalkCouldNotRun {
768    /// The submitted transaction.
769    pub subject_txid: String,
770    /// The transaction the walk stopped at.
771    pub at_txid: String,
772    /// Whether the subject's own inputs had all executed before the stop.
773    pub subject_judged: bool,
774    /// The limb it went past.
775    pub limb: WalkLimb,
776    /// The estimate against the limb, in the door's words.
777    pub what: String,
778}
779
780/// What the walk did (the door's cost instrument, since `Date.now()` is
781/// frozen during synchronous work on Workers and cannot time it).
782#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
783pub struct WalkStats {
784    /// Unproven transactions whose inputs were executed.
785    pub unproven_txs: usize,
786    /// Inputs executed (scripts run).
787    pub inputs_executed: usize,
788    /// Unlocking + locking script bytes executed.
789    pub script_bytes: usize,
790    /// Hash-class opcodes seen (SHA1/SHA256/HASH160/HASH256/RIPEMD160).
791    pub hash_ops: usize,
792    /// Signature checks seen (CHECKSIG counts 1, CHECKMULTISIG counts its
793    /// consensus maximum of 20).
794    pub sig_ops: usize,
795    /// The static work estimate charged against the budget.
796    pub work_bytes: u64,
797    /// The memory estimate charged against the budget: the frame's, made
798    /// before the stream was opened, plus the largest charge of one walked
799    /// input's scripts, made before they were parsed.
800    pub memory_bytes: u64,
801    /// Whether the SUBJECT's own inputs were all executed (an ancestor may
802    /// still have faulted afterwards).
803    pub subject_judged: bool,
804}
805
806/// Static opcode census of one input's scripts: (bytes, hash ops, sig ops).
807///
808/// A CHECKMULTISIG is weighted by its KEY COUNT: the small-int push that
809/// precedes it when the script states the count (the Poc5 covenant's
810/// `OP_3`), else the most 33-byte keys the element limit admits (the count is
811/// a computed value the census cannot see; bsv-rs allows up to `i32::MAX`).
812pub(crate) fn script_census(
813    unlocking: &[u8],
814    locking: &[u8],
815    memory_limit: usize,
816) -> Result<(usize, usize, usize), String> {
817    use bsv_rs::script::op::*;
818    let most_keys = (memory_limit / 33).max(1);
819    let mut hash_ops = 0usize;
820    let mut sig_ops = 0usize;
821    for (label, bytes) in [("unlocking", unlocking), ("locking", locking)] {
822        let script = bsv_rs::script::Script::from_binary(bytes)
823            .map_err(|e| format!("{label} script does not parse: {e}"))?;
824        let chunks = script.chunks();
825        for (i, chunk) in chunks.iter().enumerate() {
826            match chunk.op {
827                OP_RIPEMD160 | OP_SHA1 | OP_SHA256 | OP_HASH160 | OP_HASH256 => hash_ops += 1,
828                OP_CHECKSIG | OP_CHECKSIGVERIFY => sig_ops += 1,
829                OP_CHECKMULTISIG | OP_CHECKMULTISIGVERIFY => {
830                    let stated = i
831                        .checked_sub(1)
832                        .map(|j| chunks[j].op)
833                        .and_then(|op| match op {
834                            OP_1..=OP_16 => Some((op - OP_1 + 1) as usize),
835                            _ => None,
836                        });
837                    sig_ops += stated.unwrap_or(most_keys);
838                }
839                _ => {}
840            }
841        }
842    }
843    Ok((unlocking.len() + locking.len(), hash_ops, sig_ops))
844}
845
846impl Engine {
847    /// Page size used by `/requestSyncResponse` when the (public) caller
848    /// omits `limit`. A bounded page is not lossy for a conforming
849    /// initiator: ours ([`crate::gasp::GASPSync`]) keeps requesting pages
850    /// for as long as the `since` score cursor advances (gate finding M1 —
851    /// it must NOT require a "full" page to continue, because this clamp
852    /// makes a full-by-the-initiator's-limit page impossible).
853    pub const SYNC_RESPONSE_DEFAULT_LIMIT: u64 = 500;
854    /// Hard cap on a `/requestSyncResponse` page. Before bsv-low #291 an
855    /// absent/huge caller-supplied limit meant an unbounded scan of the
856    /// outputs table on a public route.
857    pub const SYNC_RESPONSE_MAX_LIMIT: u64 = 1000;
858
859    /// Clamp the public `/requestSyncResponse` page size: absent → the
860    /// default page; anything larger than the cap → the cap. The result is
861    /// ALWAYS `Some`-worthy — a sync response is never unbounded (bsv-low
862    /// #291). Completeness relies on the INITIATOR paging while its `since`
863    /// cursor advances (see `SYNC_RESPONSE_DEFAULT_LIMIT`'s doc) — the
864    /// responder alone cannot page for it.
865    pub fn clamp_sync_limit(requested: Option<u64>) -> u64 {
866        requested
867            .unwrap_or(Self::SYNC_RESPONSE_DEFAULT_LIMIT)
868            .min(Self::SYNC_RESPONSE_MAX_LIMIT)
869    }
870
871    /// Create a new Overlay Services Engine.
872    pub fn new(
873        managers: HashMap<String, Box<dyn TopicManager>>,
874        lookup_services: HashMap<String, Box<dyn LookupService>>,
875        storage: Box<dyn Storage>,
876        advertiser: Option<Box<dyn Advertiser>>,
877        config: EngineConfig,
878    ) -> Self {
879        Self::with_chain_tracker(
880            managers,
881            lookup_services,
882            storage,
883            advertiser,
884            None,
885            None,
886            config,
887        )
888    }
889
890    /// Create a new Engine with an optional ChainTracker for SPV verification.
891    pub fn with_chain_tracker(
892        managers: HashMap<String, Box<dyn TopicManager>>,
893        lookup_services: HashMap<String, Box<dyn LookupService>>,
894        storage: Box<dyn Storage>,
895        advertiser: Option<Box<dyn Advertiser>>,
896        broadcaster: Option<Box<dyn Broadcaster>>,
897        chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
898        config: EngineConfig,
899    ) -> Self {
900        Self::with_all(
901            managers,
902            lookup_services,
903            storage,
904            advertiser,
905            broadcaster,
906            None,
907            chain_tracker,
908            config,
909        )
910    }
911
912    /// Create a new Engine with all optional components.
913    #[allow(clippy::too_many_arguments)]
914    pub fn with_all(
915        managers: HashMap<String, Box<dyn TopicManager>>,
916        lookup_services: HashMap<String, Box<dyn LookupService>>,
917        storage: Box<dyn Storage>,
918        advertiser: Option<Box<dyn Advertiser>>,
919        broadcaster: Option<Box<dyn Broadcaster>>,
920        arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
921        chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
922        mut config: EngineConfig,
923    ) -> Self {
924        // Build default sync configuration: SHIP for all topics except tm_ship/tm_slap
925        // which get their trackers merged with provided shipTrackers/slapTrackers.
926        for manager_name in managers.keys() {
927            if manager_name == "tm_ship" {
928                if matches!(
929                    config.sync_configuration.get(manager_name),
930                    Some(SyncTarget::Disabled)
931                ) {
932                    continue;
933                }
934                let mut combined: HashSet<String> = HashSet::new();
935                if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
936                {
937                    combined.extend(peers.iter().cloned());
938                }
939                combined.extend(config.ship_trackers.iter().cloned());
940                if !combined.is_empty() {
941                    config.sync_configuration.insert(
942                        manager_name.clone(),
943                        SyncTarget::Peers(combined.into_iter().collect()),
944                    );
945                }
946            } else if manager_name == "tm_slap" {
947                if matches!(
948                    config.sync_configuration.get(manager_name),
949                    Some(SyncTarget::Disabled)
950                ) {
951                    continue;
952                }
953                let mut combined: HashSet<String> = HashSet::new();
954                if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
955                {
956                    combined.extend(peers.iter().cloned());
957                }
958                combined.extend(config.slap_trackers.iter().cloned());
959                if !combined.is_empty() {
960                    config.sync_configuration.insert(
961                        manager_name.clone(),
962                        SyncTarget::Peers(combined.into_iter().collect()),
963                    );
964                }
965            } else if !config.sync_configuration.contains_key(manager_name) {
966                config
967                    .sync_configuration
968                    .insert(manager_name.clone(), SyncTarget::Ship);
969            }
970        }
971
972        Self {
973            managers,
974            lookup_services,
975            storage,
976            advertiser,
977            broadcaster,
978            arc_broadcaster,
979            chain_tracker,
980            gasp_remote_factory: None,
981            ship_lookup_client: None,
982            ship_bootstrap_peers: HashMap::new(),
983            ancestor_fetcher: None,
984            peer_sync_budget: None,
985            graph_budget: None,
986            graph_budget_limbs: (
987                crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES,
988                crate::gasp::DEFAULT_GRAPH_BUDGET_NODES,
989            ),
990            finalize_submit_budget: None,
991            finalize_gate: crate::gasp::SubmitGate::default(),
992            not_landed: std::cell::RefCell::new(HashSet::new()),
993            landing_guard: None,
994            verify_scripts: true,
995            walk_budget: DoorBudget::DEFAULT,
996            config,
997        }
998    }
999
1000    /// Set the ARC broadcaster for network broadcast to miners.
1001    ///
1002    /// When set, the Engine broadcasts transactions to ARC during Phase 2
1003    /// of `submit()` for `CurrentTx` submissions.
1004    pub fn set_arc_broadcaster(&mut self, arc: Box<dyn ArcBroadcaster>) {
1005        self.arc_broadcaster = Some(arc);
1006    }
1007
1008    /// Set the GASP remote factory for peer communication during sync.
1009    ///
1010    /// Platform-specific crates (like overlay-cloudflare) provide an implementation
1011    /// that creates HTTP-based remotes using their native fetch API.
1012    pub fn set_gasp_remote_factory(&mut self, factory: Box<dyn crate::gasp::GASPRemoteFactory>) {
1013        self.gasp_remote_factory = Some(factory);
1014    }
1015
1016    /// Set the platform lookup transport for network SHIP discovery.
1017    /// Trackers come from [`EngineConfig::slap_trackers`].
1018    pub fn set_ship_lookup_client(
1019        &mut self,
1020        client: Box<dyn crate::ship_lookup::ShipLookupClient>,
1021    ) {
1022        self.ship_lookup_client = Some(client);
1023    }
1024
1025    /// Keep these peers in a topic's SHIP union even when trackers fail.
1026    /// This leaves the existing [`SyncTarget`] serialization unchanged.
1027    /// Such topics run ahead of tracker imports so discovery and sync can
1028    /// proceed without spending the tick on importing discovery records.
1029    pub fn set_ship_bootstrap_peers(&mut self, topic: impl Into<String>, peers: Vec<String>) {
1030        self.ship_bootstrap_peers.insert(topic.into(), peers);
1031    }
1032
1033    /// Set the OPT-IN chain-backed ancestor fetcher for GASP ingest.
1034    ///
1035    /// **OFF BY DEFAULT.** When set, a peer's failure to serve a needed
1036    /// ancestor during GASP ingest falls back to fetching that ancestor's raw
1037    /// tx from chain (via the supplied fetcher), self-healing the graph and
1038    /// also enabling strict-BEEF finalize so missing ancestry fails loud.
1039    ///
1040    /// This is a deliberate one-time-migration escape hatch. Production must
1041    /// NOT call this — when unset, GASP ingest behavior is unchanged.
1042    pub fn set_ancestor_fetcher(&mut self, fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>) {
1043        self.ancestor_fetcher = Some(fetcher);
1044    }
1045
1046    /// Set the admission predicate asked BEFORE the door lands a carried
1047    /// predecessor (bsv-low #575, the E1D delta-2 fold, L2;
1048    /// [`Engine::land_carried`]). It is asked once per body the landing
1049    /// would submit, before that body's submit (a dry one included) and
1050    /// before its reads are charged; an `Err` (the caller's refusal, or its
1051    /// own read fault) ends the landing with nothing of that body written
1052    /// and the successor's topic answers "not now", as on `683dffd`. The
1053    /// worker installs its eviction ledger here: an OPEN eviction of the
1054    /// body refuses it. Its read is not one of the submit's 256 (it is the
1055    /// caller's store, at most one per landed body). Unset (default): every
1056    /// carried predecessor may land. What the predicate cannot see (a row
1057    /// opened while the landing writes) stays the caller's to guard after
1058    /// the write ([`MutationReport::landed_predecessors`]).
1059    pub fn set_landing_guard(&mut self, guard: LandingGuard) {
1060        self.landing_guard = Some(guard);
1061    }
1062
1063    /// Set the per-peer GASP sync budget (bsv-low#302).
1064    ///
1065    /// `sleep` is the platform sleep factory (ms → future); `budget_ms` is
1066    /// the wall-clock slice ONE peer's sync may consume. A peer exceeding it
1067    /// is dropped (loudly logged, listed in the topic's `errors`) and the
1068    /// loop moves on. Unset (default) = unbounded, the pre-#302 behavior.
1069    ///
1070    /// **Progress survives the deadline (bsv-low #552).** With a budget set,
1071    /// every graph is submitted AS IT FINALIZES, inside the raced future (the
1072    /// reference submits inside `finalizeGraph` too), so what was finalized
1073    /// before the deadline stays admitted. At the deadline the cursor is
1074    /// advanced to `GASPSync::completed_cursor`: past the UTXOs whose graphs
1075    /// were completed, never past the one in flight. That graph is lost
1076    /// whole (nothing of it was finalized) and the next tick walks it again,
1077    /// down to whatever is now in storage.
1078    ///
1079    /// **The deadline never lands inside a transaction's writes** (the lens
1080    /// fold's HIGH-1). It is cooperative around a finalize submit
1081    /// ([`Engine::finalize_submit_gate`]): a deadline that falls due while
1082    /// one transaction is being written waits for that transaction, and the
1083    /// sync is dropped at the boundary. What a dropped tick leaves of the
1084    /// graph being submitted is an ancestors-first prefix of WHOLE
1085    /// transactions, with the cursor below its UTXO. Everywhere else (a
1086    /// request to the peer, the walk, the anchor check) the deadline drops
1087    /// the sync at once, as a budget against a dead peer must. A dropped tick that finalized a
1088    /// graph or moved the cursor is recorded as a SUCCESSFUL attempt for the
1089    /// quarantine count: a peer serving a long bootstrap is not a dead peer.
1090    /// One with no progress is a failed attempt, as before.
1091    ///
1092    /// The budget bounds the work of a TICK. Without a per-graph budget
1093    /// nothing bounds a GRAPH (parity: the reference has no node cap): one
1094    /// graph whose own walk outlasts the budget is dropped on every tick
1095    /// (`deadline_dropped_graphs`) and never admitted. With one
1096    /// ([`Engine::set_graph_budget`], bsv-low #555) that walk is deferred
1097    /// with its progress kept, here too, and resumed next tick.
1098    pub fn set_peer_sync_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1099        self.peer_sync_budget = Some((sleep, budget_ms));
1100    }
1101
1102    /// Set the per-GRAPH GASP budget and turn DEFERRAL on (bsv-low #555).
1103    ///
1104    /// A graph whose walk makes `max_calls` requests (to the peer, or chain
1105    /// fetches) or runs `budget_ms` in one pass is DEFERRED: its partial walk
1106    /// (the nodes fetched with their proofs, the inputs still pending, the
1107    /// calls spent, its age in passes) is saved as ONE record of the storage
1108    /// ([`Storage::put_deferred_graph`], replaced on every deferral), its
1109    /// UTXO is held below the cursor as a failed one is (the gap guard) and
1110    /// not walked again in the sync (#554), and the sync goes on to the next
1111    /// UTXO and topic. The next sync that is served that UTXO RESUMES the
1112    /// walk from the record, asking only what is pending, under the same
1113    /// budget; a graph so converges over passes and is then completed as
1114    /// any graph is (#551's anchor check, the finalize submits, e1d's
1115    /// rules). A walk cut by the per-peer deadline is deferred the same way.
1116    ///
1117    /// Bounds: a record deferred [`crate::gasp::DEFERRED_GRAPH_MAX_PASSES`]
1118    /// times is dropped with its reason and the UTXO fails as before (the
1119    /// gap guard asks again, from the root); a record has no byte bound
1120    /// (bsv-low #585); at most
1121    /// [`crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC`] records per (peer,
1122    /// topic). Keep `budget_ms` below the per-peer budget so that a deep
1123    /// graph leaves the pass time for the UTXOs after it. Defaults offered:
1124    /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_CALLS`],
1125    /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_MS`].
1126    ///
1127    /// Every resume of one sync shares ONE such budget (the lens fold's M1),
1128    /// so the deferred UTXOs, served first, leave the pass the rest; a walk
1129    /// that fetched nothing keeps no record and is a failed attempt for the
1130    /// quarantine (H1); a walk that cannot be kept, or whose save faulted,
1131    /// goes on under the per-peer budget alone (L4, the delta fold's D-L2);
1132    /// a FRESH walk's error keeps no record (D-M1). With a budget, a sync the
1133    /// peer served work that finalized no graph and moved no cursor is
1134    /// YIELDLESS, and past [`crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED`] in a
1135    /// row AND [`crate::gasp::PEER_YIELDLESS_SECS_ALLOWED`] since the streak
1136    /// began each is a failed attempt (D-M2, the delta-2 fold's D2-M1; the
1137    /// streak is kept by [`Storage::record_peer_sync_yield`], keyed on the
1138    /// peer's [`crate::gasp::peer_origin`] as its quarantine is). A FRESH
1139    /// walk that faults after half its calls or half its time keeps its
1140    /// record (D2-L1); a RESUMED one that faults having appended nothing
1141    /// [`crate::gasp::DEFERRED_GRAPH_MAX_IDLE_FAULTS`] passes in a row is
1142    /// dropped (D2-M2). Each record says whether its peer is configured
1143    /// (`SyncTarget::Peers`), for a storage that reserves part of its
1144    /// ceiling for those (the worker reserves half).
1145    ///
1146    /// Unset (the default) nothing is deferred and the walk is the one
1147    /// before #555 (parity: the reference has no budget and no deferral).
1148    /// The storage must KEEP records (the four required deferred-graph
1149    /// methods of [`Storage`]): over one that refuses every save, every
1150    /// graph past the budget is walked from its root under the per-peer
1151    /// budget alone on every pass, as before #555, so do not set a budget
1152    /// there.
1153    ///
1154    /// The budget has two more limbs (bsv-low #586), in force with it at
1155    /// their defaults: the bytes one graph may be SERVED in one pass
1156    /// ([`crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES`], a budget per pass only:
1157    /// a record has no byte bound since bsv-low #585 door 4) and the nodes it
1158    /// may APPEND ([`crate::gasp::DEFAULT_GRAPH_BUDGET_NODES`], 64);
1159    /// [`Engine::set_graph_budget_limbs`] names others.
1160    pub fn set_graph_budget(&mut self, sleep: SleepFactory, max_calls: u32, budget_ms: u64) {
1161        self.graph_budget = Some((sleep, max_calls, budget_ms));
1162    }
1163
1164    /// The bytes and nodes limbs of the per-graph budget (bsv-low #586,
1165    /// zanaadu-v2 #377): a graph whose walk is SERVED `max_bytes_fetched`
1166    /// bytes in one pass (the hex of each node's raw transaction and proof,
1167    /// as the peer sends it and a record keeps it, every answer counted), or
1168    /// APPENDS `max_nodes` nodes, is DEFERRED exactly as at the call budget
1169    /// (reason `bytes` or `nodes`): its record saved, its UTXO held below
1170    /// the cursor, the walk resumed by the next pass from what is pending.
1171    /// Never a drop, a refusal or a discarded graph: these are budgets PER
1172    /// PASS, not limits on a graph or a BEEF. A pass always makes one
1173    /// request whatever a node's size; the resumes of one sync share one
1174    /// such budget, as they share its calls (M1); a walk that cannot be
1175    /// KEPT goes on under the per-peer budget alone (L4), as at the calls.
1176    /// They bound what one pass adds to the pending graph and to its record
1177    /// while the anchor check and the finalize hold it in memory. No effect
1178    /// until [`Engine::set_graph_budget`] turns deferral on; the reference
1179    /// has neither. This setter is UNCAPPED, as the worker's
1180    /// `GASP_GRAPH_BUDGET_BYTES` var is (bsv-low #585 door 4: the engine keeps
1181    /// no record cap for the limb to sit under). A consumer whose STORAGE caps
1182    /// a record must keep the bytes limb under that cap, or a pass whose
1183    /// record cannot be saved walks afresh (E586-L1).
1184    pub fn set_graph_budget_limbs(&mut self, max_bytes_fetched: u64, max_nodes: u32) {
1185        self.graph_budget_limbs = (max_bytes_fetched, max_nodes);
1186    }
1187
1188    /// Bound ONE transaction's GASP finalize submit (bsv-low #559, the delta
1189    /// lens's DELTA-3). That submit is the write section no deadline drops
1190    /// ([`Engine::finalize_submit_gate`]), so a storage call or lookup hook
1191    /// that never answers inside it held the per-peer budget and any outer
1192    /// race for as long as it hung. With this set EACH storage call, lookup
1193    /// hook and topic manager call of that submit is raced against one
1194    /// `sleep(budget_ms)` of the section (the lens fold of 2026-10-07, F2):
1195    /// a call still unanswered when it falls due is dropped alone and is
1196    /// that call's FAULT, and no call is started after it. The SUBMIT is
1197    /// never dropped: it runs to its end and leaves what a fault leaves
1198    /// ([`Engine::submit_with_report`]: nothing of the transaction, or all
1199    /// of it), the transaction did not land, the rest of its graph is not
1200    /// submitted, its UTXO fails and the cursor stays below it. The undo of
1201    /// a faulted submit has one more `sleep(budget_ms)` of its own, so a
1202    /// peer costs at most its budget plus twice this one. What the backend
1203    /// does with a statement whose caller stopped waiting is its own: a
1204    /// write that lands after its timeout is not seen by this submit.
1205    ///
1206    /// DEFAULT: none (a native engine has no clock). A caller that sets a
1207    /// per-peer budget should set this too.
1208    pub fn set_finalize_submit_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1209        self.finalize_submit_budget = Some((sleep, budget_ms));
1210    }
1211
1212    /// The gate around every GASP finalize submit (bsv-low #552, the lens
1213    /// fold's HIGH-1). `start_gasp_sync` races each peer against its budget
1214    /// with [`crate::gasp::race_or_deadline_guarded`] over this gate. A
1215    /// caller that races `start_gasp_sync` ITSELF against a deadline (the
1216    /// worker's scheduled step) must use the same function over the same
1217    /// gate: a plain `race_or_deadline` there can still drop a finalize
1218    /// submit between its writes.
1219    pub fn finalize_submit_gate(&self) -> &crate::gasp::SubmitGate {
1220        &self.finalize_gate
1221    }
1222
1223    /// Turn reference-parity script verification on submit on or off.
1224    /// **DEFAULT ON** (set in every constructor).
1225    ///
1226    /// On, every submit outside `HistoricalTxNoSpv` executes every unproven
1227    /// input's unlocking script against its source output (and checks every
1228    /// merkle path against the chain tracker), exactly as the reference's
1229    /// `Engine.submit` → `tx.verify(this.chainTracker)` does. Off is an
1230    /// ESCAPE HATCH for an operator who must admit a body the interpreter
1231    /// refuses (a bsv-rs interpreter defect, say) while it is fixed. It is
1232    /// NOT a mode: it restores the pre-2026-09-08 structural check, under
1233    /// which an invalid spend that no broadcaster had yet refused was
1234    /// admitted on BEEF structure alone. Prefer
1235    /// [`crate::builder::EngineBuilder::with_script_verification`].
1236    pub fn set_script_verification(&mut self, enabled: bool) {
1237        self.verify_scripts = enabled;
1238    }
1239
1240    /// Whether submits execute input scripts, see
1241    /// [`Engine::set_script_verification`]. `true` by default.
1242    pub fn script_verification(&self) -> bool {
1243        self.verify_scripts
1244    }
1245
1246    /// The budget of the submit's own script walk (bsv-low #592, the owner's
1247    /// ruling 3a of 2026-10-10). Every submit that walks (every mode but
1248    /// `HistoricalTxNoSpv`), and the GASP anchor check, read the BEEF through
1249    /// the door's stream walk ([`crate::script_door`]) under this budget's
1250    /// two limbs, the work and the memory, each estimated from the bytes
1251    /// before it is spent. A breach is "the walk could not run"
1252    /// ([`WalkCouldNotRun`], on [`MutationReport::walk_could_not_run`]),
1253    /// never a refusal: the submit goes on as under `HistoricalTxNoSpv`.
1254    /// A library consumer that sets nothing gets [`DoorBudget::DEFAULT`]
1255    /// (64 MiB of work, 48 MiB of memory). Prefer
1256    /// [`crate::builder::EngineBuilder::with_walk_budget`].
1257    pub fn set_walk_budget(&mut self, budget: DoorBudget) {
1258        self.walk_budget = budget;
1259    }
1260
1261    /// The budget of the submit's own script walk, see
1262    /// [`Engine::set_walk_budget`].
1263    pub fn walk_budget(&self) -> DoorBudget {
1264        self.walk_budget
1265    }
1266
1267    /// The reference's `tx.verify('scripts only')` over a BEEF: every unproven
1268    /// transaction from `subject_txid` down executes every input's unlocking
1269    /// script against its source output and obeys the value rule, while a
1270    /// transaction that carries a merkle path is TRUSTED AS-IS — no root is
1271    /// computed and no chain tracker is consulted (the ts-sdk's own shape when
1272    /// `chainTracker === 'scripts only'`: a proven tx is added to the verified
1273    /// set and the walk stops there).
1274    ///
1275    /// Built for an admission path whose bar is the NETWORK, not a proof
1276    /// (bsv-low's `broadcast-gated`, register row D1): the overlay broadcasts
1277    /// and admits on network evidence, so a lagging tracker must never read as
1278    /// a refused transaction — but a spend the interpreter refuses is one the
1279    /// network will refuse too, and executing it at the door saves the
1280    /// broadcast and names the fault. Independent of
1281    /// [`Engine::set_script_verification`] (that switch governs `submit`; this
1282    /// is an explicit ask) and of [`Engine::submit`]'s mode (`HistoricalTxNoSpv`
1283    /// still skips, as the reference does).
1284    ///
1285    /// Runs under the engine's budget ([`Engine::set_walk_budget`], the
1286    /// submit's own; [`DoorBudget::DEFAULT`] unless set: one budget per
1287    /// engine, the E592 lens fold's NOTE-2): the work is bounded statically
1288    /// before anything executes, and the interpreter's stack memory limit is
1289    /// the budget's element limit. The body is read through the streaming
1290    /// reader ([`crate::script_door`], bsv-low #585): no bound on its size,
1291    /// its transactions or a transaction's inputs or bytes; the budget's two
1292    /// limbs are the work and the memory beside the body.
1293    ///
1294    /// Errors: [`EngineError::ScriptVerificationFailed`] is the INTERPRETER's
1295    /// verdict and the only refusal; [`EngineError::ScriptWalkInconclusive`]
1296    /// is a structural fault (a transaction or source missing from the BEEF,
1297    /// a parse fault, the value rule) and [`EngineError::ScriptWalkOverBudget`]
1298    /// the door's own bound (the static budget or the memory limit) — both
1299    /// name whether the SUBJECT was judged before the fault, and a caller with
1300    /// a stronger bar behind it does not refuse on either.
1301    #[allow(
1302        clippy::unused_async,
1303        reason = "the door's callers await it on an engine; 'scripts only' asks no tracker"
1304    )]
1305    pub async fn verify_scripts_only(
1306        &self,
1307        beef_bytes: &[u8],
1308        subject_txid: &str,
1309    ) -> Result<WalkStats, EngineError> {
1310        crate::script_door::walk(beef_bytes, subject_txid, self.walk_budget)
1311    }
1312
1313    /// [`Engine::verify_scripts_only`] under a budget the caller names (the
1314    /// pins measure the door with a limb lifted; a caller with another
1315    /// platform's ceiling sets its own).
1316    #[allow(
1317        clippy::unused_async,
1318        clippy::unused_self,
1319        reason = "the twin of `verify_scripts_only`"
1320    )]
1321    pub async fn verify_scripts_only_under(
1322        &self,
1323        beef_bytes: &[u8],
1324        subject_txid: &str,
1325        budget: DoorBudget,
1326    ) -> Result<WalkStats, EngineError> {
1327        crate::script_door::walk(beef_bytes, subject_txid, budget)
1328    }
1329
1330    // ========================================================================
1331    // Submit — 3-phase pipeline
1332    // ========================================================================
1333
1334    /// Validate a tagged BEEF without applying mutations.
1335    ///
1336    /// Runs Phase 1 (topic validation) and Phase 2 (broadcast) but NOT Phase 3
1337    /// (storage mutations). Returns the Steak (admittance decisions) that
1338    /// `submit()` would return.
1339    ///
1340    /// Used by the onSteakReady pattern to return results to clients before
1341    /// mutations are applied via a queue consumer or `ctx.wait_until()`.
1342    ///
1343    /// The topic managers are called with `dry_run: true`
1344    /// (`TopicAdmittanceContext::DRY_RUN`): nothing is admitted on this call, so a
1345    /// manager that writes on admission leaves no trace of it.
1346    pub async fn submit_validate_only(
1347        &self,
1348        tagged_beef: &TaggedBEEF,
1349        mode: SubmitMode,
1350    ) -> Result<Steak, EngineError> {
1351        // A validate-only call admits nothing: a dry run by its own name. A
1352        // manager that writes on admission must not write here, or the real
1353        // submit that follows meets a head this call already advanced.
1354        let (_validations, steak, _tx, _txid, _could_not_run) = self
1355            .run_validation(
1356                tagged_beef,
1357                mode,
1358                &TopicAdmittanceContext::DRY_RUN,
1359                None,
1360                None,
1361                WalkBreachPolicy::NotNow,
1362            )
1363            .await?;
1364        Ok(steak)
1365    }
1366
1367    /// Submit a transaction for processing by the overlay.
1368    ///
1369    /// Three phases:
1370    /// 1. **VALIDATE** — check topics, dedup, find previous coins, call topic managers
1371    /// 2. **BROADCAST** — broadcast to network (if mode is CurrentTx)
1372    /// 3. **MUTATE** — mark spent, delete stale, insert new outputs, notify lookup services
1373    ///
1374    /// Returns a STEAK mapping each topic to its admittance instructions.
1375    ///
1376    /// Phase-3 faults are reported through [`Engine::submit_with_report`];
1377    /// this convenience keeps the historical signature and only LOGS the
1378    /// report's summary. A caller that must not ack an admission whose
1379    /// writes did not land (the `/submit` route — S2) uses the report form.
1380    pub async fn submit(
1381        &self,
1382        tagged_beef: &TaggedBEEF,
1383        mode: SubmitMode,
1384    ) -> Result<Steak, EngineError> {
1385        let (steak, report) = self.submit_with_report(tagged_beef, mode).await?;
1386        if !report.is_durable() {
1387            warn!(
1388                "submit: {} mutation fault(s) — not durable: {}",
1389                report.faults.len(),
1390                report.summary()
1391            );
1392        }
1393        Ok(steak)
1394    }
1395
1396    /// [`Engine::submit`] plus the Phase-3 durability report (S2
1397    /// queue-durable admission, bsv-low 2026-08-29).
1398    ///
1399    /// The STEAK is the admission DECISION; the report says whether the
1400    /// decision was DURABLY WRITTEN. They are returned together because
1401    /// they are answered by different things: Phase 1+2 decide, Phase 3
1402    /// lands, and only the caller knows whether an undurable landing may
1403    /// be acked (never, on the money path — re-queue it instead).
1404    ///
1405    /// Every write failure is still logged AND still non-fatal to the
1406    /// other topics (a faulted topic does not un-admit its siblings); what
1407    /// changed is that the failure is also VISIBLE, and that the faulted
1408    /// topic is not recorded as applied — so a replay of the same bytes is
1409    /// re-validated and re-written (every backend write is idempotent:
1410    /// `INSERT OR IGNORE` / `OR REPLACE` / `UPDATE`).
1411    ///
1412    /// What a fault leaves (bsv-low #559 and its lens fold of 2026-10-07):
1413    /// a topic's previous coins are deleted once EVERY admitted output of
1414    /// the transaction is inserted, and only then. A fault before that
1415    /// (a validation read, the spent mark of a coin the manager retains, an
1416    /// insert) leaves the previous coins held and nothing of the
1417    /// transaction: what did get inserted is taken out again, so the replay
1418    /// is the same judgement and no successor can spend half of it. A fault
1419    /// after it (a lookup notification, the consumed-by update, the applied
1420    /// row) leaves the transaction's outputs and no previous coin. A fault
1421    /// before any delete was started (the record of the spent coin's
1422    /// transaction, the read of the coin) is the first case if the coins
1423    /// are read back still held; once a delete was started its fault never
1424    /// undoes, answered or not (it may land yet): the outputs stay beside
1425    /// the stale coin, marked spent. A submit that deletes a stale coin
1426    /// first records that coin's transaction as applied, so a replay of it
1427    /// after its successor is a dupe. So a non-retaining chain never has
1428    /// two unspent heads and never none, whatever single call faults. The
1429    /// applied row is withheld on ANY fault. And a successor that was
1430    /// judged without the coin an unlanded predecessor has yet to leave is
1431    /// reported as a fault (`predecessor_not_landed`), not recorded as
1432    /// applied.
1433    ///
1434    /// A walk that could not run within the engine's budget is "not now"
1435    /// here ([`WalkBreachPolicy::NotNow`], [`EngineError::WalkCouldNotRun`]):
1436    /// never an admission. [`Engine::submit_with_report_under`] names the
1437    /// policy.
1438    pub async fn submit_with_report(
1439        &self,
1440        tagged_beef: &TaggedBEEF,
1441        mode: SubmitMode,
1442    ) -> Result<(Steak, MutationReport), EngineError> {
1443        self.submit_with_report_under(tagged_beef, mode, WalkBreachPolicy::NotNow)
1444            .await
1445    }
1446
1447    /// [`Engine::submit_with_report`] under an explicit
1448    /// [`WalkBreachPolicy`] (the E592 lens fold, H1): what a walk that could
1449    /// not run within the budget does. `NetworkAccepted` is for a caller that
1450    /// holds the network's accept of these very bytes (the queue's replay of
1451    /// a gated submission) and for nothing else: it admits a breached body
1452    /// with its scripts unrun.
1453    pub async fn submit_with_report_under(
1454        &self,
1455        tagged_beef: &TaggedBEEF,
1456        mode: SubmitMode,
1457        policy: WalkBreachPolicy,
1458    ) -> Result<(Steak, MutationReport), EngineError> {
1459        self.submit_bounded(tagged_beef, mode, None, false, policy)
1460            .await
1461    }
1462
1463    /// [`Engine::submit_with_report`] with every storage call, lookup hook
1464    /// and topic manager call under `bound` (the GASP finalize submit,
1465    /// [`CallBound`]); `None` is the unbounded submit of every other door.
1466    /// `finalize` is `true` for the GASP finalize submit alone, bounded or
1467    /// not: it does not ask the store's predecessor question.
1468    async fn submit_bounded(
1469        &self,
1470        tagged_beef: &TaggedBEEF,
1471        mode: SubmitMode,
1472        bound: Option<&CallBound>,
1473        finalize: bool,
1474        policy: WalkBreachPolicy,
1475    ) -> Result<(Steak, MutationReport), EngineError> {
1476        // The reads the store's predecessor question has made in this
1477        // submit, every topic together ([`PREDECESSOR_READS_PER_SUBMIT`]),
1478        // the predecessors the door lands first from the BEEF included.
1479        let mut question_reads = 0usize;
1480        self.submit_counted(
1481            tagged_beef,
1482            mode,
1483            bound,
1484            finalize,
1485            None,
1486            &mut question_reads,
1487            policy,
1488        )
1489        .await
1490        .map(|(steak, report, _)| (steak, report))
1491    }
1492
1493    /// [`Engine::submit_bounded`] over the submit's read allowance
1494    /// (`question_reads`), which a predecessor the door lands first from the
1495    /// BEEF shares with the successor that carried it (lane E1D's lens fold,
1496    /// M1). `carried` is `Some` for such a predecessor's own submit
1497    /// ([`Engine::land_carried`]), holding the bodies whose SPV walk already
1498    /// passed in this submit (none is walked twice; the delta fold, L4): it
1499    /// lands nothing first itself, and it names instead the carried
1500    /// predecessor that blocks each topic (the third value), so the landing
1501    /// is an explicit stack and never a recursion of submits (nested polls
1502    /// grow the stack, and a Worker's is small). Its manager is asked as a
1503    /// DRY RUN first, and for real only once its topic is not blocked: a
1504    /// link blocked below is judged twice and admitted once. Boxed: the
1505    /// landing is a submit of its own.
1506    #[allow(clippy::too_many_arguments)]
1507    fn submit_counted<'a>(
1508        &'a self,
1509        tagged_beef: &'a TaggedBEEF,
1510        mode: SubmitMode,
1511        bound: Option<&'a CallBound>,
1512        finalize: bool,
1513        carried: Option<&'a HashSet<String>>,
1514        question_reads: &'a mut usize,
1515        policy: WalkBreachPolicy,
1516    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Counted, EngineError>> + 'a>>
1517    {
1518        Box::pin(async move {
1519            self.submit_counted_inner(
1520                tagged_beef,
1521                mode,
1522                bound,
1523                finalize,
1524                carried,
1525                question_reads,
1526                policy,
1527            )
1528            .await
1529        })
1530    }
1531
1532    #[allow(clippy::too_many_arguments)]
1533    async fn submit_counted_inner(
1534        &self,
1535        tagged_beef: &TaggedBEEF,
1536        mode: SubmitMode,
1537        bound: Option<&CallBound>,
1538        finalize: bool,
1539        walked: Option<&HashSet<String>>,
1540        question_reads: &mut usize,
1541        policy: WalkBreachPolicy,
1542    ) -> Result<Counted, EngineError> {
1543        // A submit is a real admission, never a dry run; a carried
1544        // predecessor's is judged dry until its topic is known not to wait.
1545        let carried = walked.is_some();
1546        let context = if carried {
1547            TopicAdmittanceContext::DRY_RUN
1548        } else {
1549            TopicAdmittanceContext::default()
1550        };
1551        let (mut validations, mut steak, tx, txid, could_not_run) = self
1552            .run_validation(tagged_beef, mode, &context, bound, walked, policy)
1553            .await?;
1554        let mut report = MutationReport {
1555            walk_could_not_run: could_not_run,
1556            ..MutationReport::default()
1557        };
1558        // The body every LOOKUP SERVICE receives NAMES the subject (BRC-95
1559        // atomic prefix over the WHOLE submitted body — bsv-rs's
1560        // `to_binary_atomic` sorts, names, never prunes). Lookup services
1561        // re-parse it with `from_beef(_, None)`; on a plain incomplete body
1562        // that pick is the wire-last ANCESTOR, so the pot index's admit hook
1563        // shape-checked a hop and wrote no record while the engine had
1564        // admitted the pot (loop-2 fleet, 2026-09-05; the live proof after
1565        // the engine's own subject fix). A body that cannot be re-serialized
1566        // (never, for a parsed subject) is handed through as submitted.
1567        let subject_named_beef: Vec<u8> =
1568            beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
1569                .ok()
1570                .and_then(|mut b| b.to_binary_atomic(&txid).ok())
1571                .unwrap_or_else(|| tagged_beef.beef.clone());
1572
1573        // The successor rule, asked of every topic that found no coin before
1574        // anything of this submit is written ([`Engine::successors_waiting`]):
1575        // the topics that must wait, each with why.
1576        let mut blocked: HashMap<String, String> = HashMap::new();
1577        // The landing trusts the subject's walk only if it RAN (the E592
1578        // lens fold, M1): after a breach nothing it reaches was walked.
1579        let landing = Landing {
1580            policy,
1581            subject_walk_ran: report.walk_could_not_run.is_none(),
1582        };
1583        let waits = self
1584            .successors_waiting(
1585                &tx,
1586                &txid,
1587                &subject_named_beef,
1588                &mut validations,
1589                &mut steak,
1590                tagged_beef.off_chain_values.as_deref(),
1591                mode,
1592                bound,
1593                finalize,
1594                !carried,
1595                &mut blocked,
1596                question_reads,
1597                &mut report.landed_predecessors,
1598                landing,
1599                &mut report.landed_walks_could_not_run,
1600            )
1601            .await;
1602        // A carried predecessor's topic that does not wait is judged again,
1603        // for real: what it admits is this call's word, over the same coins.
1604        if carried {
1605            for v in &mut validations {
1606                if v.is_dupe || v.failed || v.read_fault.is_some() || waits.contains_key(&v.topic) {
1607                    continue;
1608                }
1609                match self
1610                    .judge(
1611                        &tx,
1612                        &v.topic,
1613                        &v.previous_coins,
1614                        tagged_beef.off_chain_values.as_deref(),
1615                        mode,
1616                        &TopicAdmittanceContext::default(),
1617                        bound,
1618                    )
1619                    .await
1620                {
1621                    Ok(admittance) => v.admittance = admittance,
1622                    Err(e) => {
1623                        error!("Error validating topic {} during submit: {e}", v.topic);
1624                        v.failed = true;
1625                        v.previous_coins.clear();
1626                        v.previous_outputs.clear();
1627                        v.admittance = AdmittanceInstructions::default();
1628                    }
1629                }
1630                steak.insert(v.topic.clone(), v.admittance.clone());
1631            }
1632        }
1633
1634        // =================================================================
1635        // PHASE 3: MUTATE STORAGE
1636        // =================================================================
1637        for v in &validations {
1638            if v.is_dupe {
1639                report.deduped_topics.push(v.topic.clone());
1640                continue;
1641            }
1642            // A failed topic comes first, a read fault of it included: a
1643            // previous-coin read that faulted and a manager that then ERRED
1644            // is a failed topic with a durable report, no fault and no
1645            // replay. Parity: the reference fails the topic and never
1646            // replays. Nothing was written, so the store is as it was.
1647            if v.failed {
1648                continue;
1649            }
1650
1651            let topic = &v.topic;
1652            let admittance = &v.admittance;
1653            let faults_before = report.faults.len();
1654
1655            // A validation read that faulted: the manager judged without a
1656            // coin the store may hold, so NOTHING is written on that
1657            // judgement (the lens fold of 2026-10-07). Writing its outputs
1658            // would leave them beside the unseen coin, for a successor to
1659            // spend before the replay deletes it.
1660            if let Some((read, err)) = &v.read_fault {
1661                report.fault(topic, read, err.clone());
1662                self.hold_unapplied(&txid, topic, "a validation read faulted, nothing written");
1663                continue;
1664            }
1665
1666            // bsv-low #559 and lane E1D: a topic that found no coin while a
1667            // predecessor's landing is unknown is "not now", recorded
1668            // nowhere ([`Engine::successors_waiting`]).
1669            if let Some(not_now) = waits.get(topic) {
1670                report.fault(topic, "predecessor_not_landed", not_now.clone());
1671                self.hold_unapplied(
1672                    &txid,
1673                    topic,
1674                    &format!("found no coin and its predecessor {not_now}"),
1675                );
1676                continue;
1677            }
1678
1679            // ── Handle stale vs retained previous coins ──
1680            let mut outputs_consumed: Vec<Outpoint> = Vec::new();
1681            let mut stale_coins: Vec<&Output> = Vec::new();
1682
1683            for (coin_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1684                let input_index = v.previous_coins[coin_idx];
1685                if admittance.coins_to_retain.contains(&input_index) {
1686                    // Retained: track as consumed (will update consumedBy later)
1687                    outputs_consumed
1688                        .push(Outpoint::new(&prev_output.txid, prev_output.output_index));
1689                } else {
1690                    // Not retained: mark as stale for deletion
1691                    stale_coins.push(prev_output);
1692                }
1693            }
1694
1695            // Update STEAK with removed coins
1696            if let Some(steak_entry) = steak.get_mut(topic) {
1697                steak_entry.coins_removed = Some(
1698                    stale_coins
1699                        .iter()
1700                        .enumerate()
1701                        .filter_map(|(i, _)| {
1702                            // Map back to input indices
1703                            v.previous_coins.get(i).copied()
1704                        })
1705                        .collect(),
1706                );
1707            }
1708
1709            // ── Mark previous outputs as spent + notify lookup services ──
1710            // A mark that faults does not skip the notification (the
1711            // reference skips it): the coin is spent whatever the row says,
1712            // a stale one is deleted below and its lookup services must
1713            // know. The mark of a RETAINED coin is part of what landed: that
1714            // row stays, and left unspent it would be listed as a UTXO
1715            // beside the output that consumed it.
1716            let mut retained_mark_faulted = false;
1717            for (prev_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1718                if let Err(e) = stored(
1719                    bound,
1720                    self.storage.mark_utxo_as_spent(
1721                        &prev_output.txid,
1722                        prev_output.output_index,
1723                        topic,
1724                    ),
1725                )
1726                .await
1727                {
1728                    error!("Error marking UTXO as spent: {e}");
1729                    report.fault(topic, "mark_utxo_as_spent", e.to_string());
1730                    retained_mark_faulted |= admittance
1731                        .coins_to_retain
1732                        .contains(&v.previous_coins[prev_idx]);
1733                }
1734
1735                // The input index within tx.inputs that spends this previous output.
1736                let spending_input_idx = v.previous_coins[prev_idx] as usize;
1737
1738                // Notify all lookup services about the spent output
1739                for ls in self.lookup_services.values() {
1740                    let payload = match ls.spend_notification_mode() {
1741                        SpendNotificationMode::None => OutputSpent::None {
1742                            txid: prev_output.txid.clone(),
1743                            output_index: prev_output.output_index,
1744                            topic: topic.clone(),
1745                        },
1746                        SpendNotificationMode::Txid => OutputSpent::Txid {
1747                            txid: prev_output.txid.clone(),
1748                            output_index: prev_output.output_index,
1749                            topic: topic.clone(),
1750                            spending_txid: txid.clone(),
1751                        },
1752                        SpendNotificationMode::Script => {
1753                            // Extract unlocking script and sequence from the spending input
1754                            let input = &tx.inputs[spending_input_idx];
1755                            let unlocking_script = input
1756                                .unlocking_script
1757                                .as_ref()
1758                                .map(bsv_rs::UnlockingScript::to_binary)
1759                                .unwrap_or_default();
1760                            OutputSpent::Script {
1761                                txid: prev_output.txid.clone(),
1762                                output_index: prev_output.output_index,
1763                                topic: topic.clone(),
1764                                spending_txid: txid.clone(),
1765                                input_index: spending_input_idx as u32,
1766                                unlocking_script,
1767                                sequence_number: input.sequence,
1768                                off_chain_values: tagged_beef.off_chain_values.clone(),
1769                            }
1770                        }
1771                        SpendNotificationMode::WholeTx => OutputSpent::WholeTx {
1772                            txid: prev_output.txid.clone(),
1773                            output_index: prev_output.output_index,
1774                            topic: topic.clone(),
1775                            spending_atomic_beef: subject_named_beef.clone(),
1776                            off_chain_values: tagged_beef.off_chain_values.clone(),
1777                        },
1778                    };
1779                    if let Err(e) = hooked(bound, ls.output_spent(&payload)).await {
1780                        error!("Error notifying lookup service of spent output: {e}");
1781                        report.fault(topic, "lookup_service.output_spent", e);
1782                    }
1783                }
1784            }
1785            if retained_mark_faulted {
1786                self.hold_unapplied(
1787                    &txid,
1788                    topic,
1789                    "the spent mark of a retained coin faulted, nothing inserted",
1790                );
1791                continue;
1792            }
1793
1794            // ── Insert admitted outputs ──
1795            // The inserts stop at the first one that faults: what follows is
1796            // the undo, not the rest of the transaction.
1797            let mut new_utxos: Vec<Outpoint> = Vec::new();
1798            let mut attempted: Vec<u32> = Vec::new();
1799            let mut insert_faulted = false;
1800
1801            for &output_index in &admittance.outputs_to_admit {
1802                let (script_bytes, sats) =
1803                    if let Some(tx_output) = tx.outputs.get(output_index as usize) {
1804                        (
1805                            tx_output.locking_script.to_binary(),
1806                            tx_output.get_satoshis(),
1807                        )
1808                    } else {
1809                        (Vec::new(), 0)
1810                    };
1811
1812                let output = Output {
1813                    txid: txid.clone(),
1814                    output_index,
1815                    output_script: script_bytes,
1816                    satoshis: sats,
1817                    topic: topic.clone(),
1818                    spent: false,
1819                    outputs_consumed: outputs_consumed.clone(),
1820                    consumed_by: Vec::new(),
1821                    beef: Some(tagged_beef.beef.clone()),
1822                    block_height: None,
1823                    score: Some(current_timestamp_ms()),
1824                };
1825
1826                attempted.push(output_index);
1827                if let Err(e) = stored(bound, self.storage.insert_output(&output)).await {
1828                    error!("Error inserting output for topic {topic}: {e}");
1829                    report.fault(topic, "insert_output", e.to_string());
1830                    insert_faulted = true;
1831                    break;
1832                }
1833
1834                new_utxos.push(Outpoint::new(&txid, output_index));
1835
1836                // Notify lookup services
1837                for ls in self.lookup_services.values() {
1838                    let (ls_script, ls_sats) =
1839                        if let Some(tx_out) = tx.outputs.get(output_index as usize) {
1840                            (tx_out.locking_script.to_binary(), tx_out.get_satoshis())
1841                        } else {
1842                            (Vec::new(), 0)
1843                        };
1844
1845                    let payload = match ls.admission_mode() {
1846                        AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
1847                            txid: txid.clone(),
1848                            output_index,
1849                            topic: topic.clone(),
1850                            satoshis: ls_sats,
1851                            locking_script: ls_script,
1852                            off_chain_values: tagged_beef.off_chain_values.clone(),
1853                        },
1854                        AdmissionMode::WholeTx => OutputAdmittedByTopic::WholeTx {
1855                            atomic_beef: subject_named_beef.clone(),
1856                            output_index,
1857                            topic: topic.clone(),
1858                            off_chain_values: tagged_beef.off_chain_values.clone(),
1859                        },
1860                    };
1861
1862                    if let Err(e) = hooked(bound, ls.output_admitted_by_topic(&payload)).await {
1863                        error!("Error notifying lookup service: {e}");
1864                        report.fault(topic, "lookup_service.output_admitted_by_topic", e);
1865                    }
1866                }
1867            }
1868
1869            // An insert faulted: the transaction did not land, so NOTHING of
1870            // it stays (the lens fold of 2026-10-07, F1). The previous coins
1871            // are kept (#559: with the delete first, one faulted insert left
1872            // a non-retaining head chain with NO head) and the outputs that
1873            // were inserted are taken out again, the faulted one included (a
1874            // backend's insert can fault after its row landed). Left in,
1875            // they could be spent and deleted by a successor before the
1876            // replay, and the replay, which still finds the previous coin,
1877            // would insert them a second time: a spent head back among the
1878            // UTXOs for good.
1879            if insert_faulted {
1880                if let Some(bound) = bound {
1881                    bound.renew();
1882                }
1883                self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
1884                    .await;
1885                self.hold_unapplied(
1886                    &txid,
1887                    topic,
1888                    &format!(
1889                        "an insert faulted: {} previous coin(s) kept, nothing of it held",
1890                        stale_coins.len()
1891                    ),
1892                );
1893                continue;
1894            }
1895
1896            // ── Delete stale outputs recursively ──
1897            // AFTER the inserts, and once every one of them landed (bsv-low
1898            // #559 and the lens fold of 2026-10-07, F1). The reference
1899            // deletes first (`applyTopicStorageMutation`: `removeStaleOutputs`,
1900            // then `admitOutput`), catches a throw per topic and never
1901            // replays; here a fault is survived and replayed, so the order
1902            // is ours, an addition. The delete does NOT wait for a clean
1903            // report (c9921ee did, and one faulted mark or notification
1904            // after the insert then left the stale coin beside the new
1905            // output for good: two unspent heads): a fault of the mark, of
1906            // a notification, deletes as the reference does.
1907            //
1908            // Before a stale coin is deleted its transaction is recorded as
1909            // applied in the topic (the delta fold of 2026-10-07, H2; an
1910            // addition, one idempotent write per deleted coin). A held coin
1911            // proves the inserts of its transaction landed, but that
1912            // transaction may have no applied row (a fault after its
1913            // inserts withheld it, or its insert landed after its timeout),
1914            // and then its replay is judged again: a manager that admits it
1915            // with NO previous coin (the opener of a chain) had it inserted
1916            // a second time, unspent, beside the output that spent it, for
1917            // good. With the row its replay is a dupe. A record that faults
1918            // leaves its coin undeleted: that is a delete fault, below.
1919            let due_before_the_delete = bound.is_some_and(CallBound::is_due);
1920            let mut delete_faulted = false;
1921            let mut delete_started = false;
1922            for stale in &stale_coins {
1923                let spent_coin_tx = AppliedTransaction {
1924                    txid: stale.txid.clone(),
1925                    topic: topic.clone(),
1926                };
1927                if let Err(e) = stored(
1928                    bound,
1929                    self.storage.insert_applied_transaction(&spent_coin_tx),
1930                )
1931                .await
1932                {
1933                    error!(
1934                        "Error recording {} as applied before its coin is deleted from topic {topic}: {e}",
1935                        stale.txid
1936                    );
1937                    report.fault(topic, "record_spent_coin_applied", e.to_string());
1938                    delete_faulted = true;
1939                    continue;
1940                }
1941                self.not_landed
1942                    .borrow_mut()
1943                    .remove(&(stale.txid.clone(), topic.clone()));
1944                match stored(
1945                    bound,
1946                    self.storage.find_output(
1947                        &stale.txid,
1948                        stale.output_index,
1949                        Some(topic),
1950                        None,
1951                        false,
1952                    ),
1953                )
1954                .await
1955                {
1956                    Ok(Some(stale_output)) => {
1957                        delete_started = true;
1958                        if let Err(e) = self.delete_utxo_deep(&stale_output, bound).await {
1959                            error!("Error deleting stale output for topic {topic}: {e}");
1960                            report.fault(topic, "delete_utxo_deep", e.to_string());
1961                            delete_faulted = true;
1962                        }
1963                    }
1964                    Ok(None) => {}
1965                    Err(e) => {
1966                        error!("Error reading stale output for topic {topic}: {e}");
1967                        report.fault(topic, "find_output", e.to_string());
1968                        delete_faulted = true;
1969                    }
1970                }
1971            }
1972
1973            // A fault here, and what it leaves. The store is put back as it
1974            // was found (the inserts undone, the replay does the whole
1975            // transaction) only when NO delete was started: the fault is of
1976            // H2's record or of the read before the delete, the call
1977            // answered, and EVERY stale coin is read back still held.
1978            //
1979            // Once a delete was STARTED nothing is undone, whether it
1980            // answered or not (the delta fold of 2026-10-07, H1, for a call
1981            // that did not answer; the second delta fold, M1, for one that
1982            // answered an error). A statement can land after its caller was
1983            // told it failed, as after its caller stopped waiting: the coin
1984            // is read back held now and is deleted a moment later, and the
1985            // undo then left the chain with no head, for good, for a manager
1986            // that needs the previous coin. So the outputs stay, the stale
1987            // coin stays (marked spent) until its delete lands or is done
1988            // again, and there is no applied row. Since H2 every order from
1989            // there converges. The replay first, the delete never landed:
1990            // the coin is found, the manager admits again, the inserts are
1991            // no-ops, the delete is done and the row written. The replay
1992            // first, the delete landed late: no previous coin, so a manager
1993            // that needs one admits nothing and the transaction is recorded
1994            // with its outputs held. A successor first: it recorded this
1995            // transaction as applied (H2) and the replay is a dupe; the
1996            // stale coin's spent row is then left behind, no UTXO.
1997            //
1998            // A call that did not ANSWER before any delete (the record, the
1999            // read) undoes nothing either: it is not known what it did. A
2000            // deadline that was already due BEFORE the first of these calls
2001            // started nothing here, so the coins are certainly held and the
2002            // store is put back.
2003            if delete_faulted {
2004                let unanswered = !due_before_the_delete && bound.is_some_and(CallBound::is_due);
2005                if let Some(bound) = bound {
2006                    bound.renew();
2007                }
2008                let mut all_held = !unanswered && !delete_started;
2009                for stale in &stale_coins {
2010                    if !all_held {
2011                        break;
2012                    }
2013                    let read = stored(
2014                        bound,
2015                        self.storage.find_output(
2016                            &stale.txid,
2017                            stale.output_index,
2018                            Some(topic),
2019                            None,
2020                            false,
2021                        ),
2022                    )
2023                    .await;
2024                    if !matches!(read, Ok(Some(_))) {
2025                        all_held = false;
2026                        break;
2027                    }
2028                }
2029                if all_held {
2030                    self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
2031                        .await;
2032                }
2033                self.hold_unapplied(
2034                    &txid,
2035                    topic,
2036                    if all_held {
2037                        "no delete of a stale coin was started: the coins are held, its outputs taken out"
2038                    } else if unanswered {
2039                        "the delete of a stale coin did not answer: nothing undone, its outputs stay"
2040                    } else if delete_started {
2041                        "the delete of a stale coin faulted: nothing undone, its outputs stay"
2042                    } else {
2043                        "no delete was started and a stale coin was not read back held: nothing undone, its outputs stay"
2044                    },
2045                );
2046                continue;
2047            }
2048
2049            // ── Update consumedBy on retained previous outputs ──
2050            for consumed_outpoint in &outputs_consumed {
2051                let consumed_output = match stored(
2052                    bound,
2053                    self.storage.find_output(
2054                        &consumed_outpoint.txid,
2055                        consumed_outpoint.output_index,
2056                        Some(topic),
2057                        None,
2058                        false,
2059                    ),
2060                )
2061                .await
2062                {
2063                    Ok(Some(o)) => o,
2064                    Ok(None) => continue,
2065                    Err(e) => {
2066                        error!("Error reading consumed output for topic {topic}: {e}");
2067                        report.fault(topic, "find_output", e.to_string());
2068                        continue;
2069                    }
2070                };
2071                {
2072                    let mut new_consumed_by = consumed_output.consumed_by.clone();
2073                    for new_utxo in &new_utxos {
2074                        if !new_consumed_by.iter().any(|c| {
2075                            c.txid == new_utxo.txid && c.output_index == new_utxo.output_index
2076                        }) {
2077                            new_consumed_by.push(new_utxo.clone());
2078                        }
2079                    }
2080                    if let Err(e) = stored(
2081                        bound,
2082                        self.storage.update_consumed_by(
2083                            &consumed_outpoint.txid,
2084                            consumed_outpoint.output_index,
2085                            topic,
2086                            &new_consumed_by,
2087                        ),
2088                    )
2089                    .await
2090                    {
2091                        error!("Error updating consumedBy: {e}");
2092                        report.fault(topic, "update_consumed_by", e.to_string());
2093                    }
2094                }
2095            }
2096
2097            // Record applied transaction — ONLY when every write above
2098            // landed. A faulted topic stays unrecorded so the Phase-1 dedup
2099            // cannot turn its replay into a no-op (S2).
2100            if report.faults.len() > faults_before {
2101                self.hold_unapplied(
2102                    &txid,
2103                    topic,
2104                    &format!(
2105                        "{} mutation fault(s); a replay of these bytes re-applies",
2106                        report.faults.len() - faults_before
2107                    ),
2108                );
2109                continue;
2110            }
2111            let tx_record = AppliedTransaction {
2112                txid: txid.clone(),
2113                topic: topic.clone(),
2114            };
2115            match stored(bound, self.storage.insert_applied_transaction(&tx_record)).await {
2116                Ok(()) => {
2117                    report.applied_topics.push(topic.clone());
2118                    self.not_landed
2119                        .borrow_mut()
2120                        .remove(&(txid.clone(), topic.clone()));
2121                }
2122                Err(e) => {
2123                    error!("Error inserting applied transaction for topic {topic}: {e}");
2124                    report.fault(topic, "insert_applied_transaction", e.to_string());
2125                    self.not_landed
2126                        .borrow_mut()
2127                        .insert((txid.clone(), topic.clone()));
2128                }
2129            }
2130        }
2131
2132        Ok((steak, report, blocked))
2133    }
2134
2135    /// The successor rule (bsv-low #559; lane E1D, bsv-low #575; and its
2136    /// lens fold of 2026-10-08), asked of every topic of a submit that found
2137    /// NO previous coin while the transaction spends something, before
2138    /// anything of the submit is written. Returns the topics that must wait,
2139    /// each with why: those are reported as a fault (`predecessor_not_landed`)
2140    /// and recorded nowhere, "not now". An addition to the reference, which
2141    /// records every non-failed topic.
2142    ///
2143    /// Such a topic was judged without the coin a predecessor has yet to
2144    /// leave. Recorded as applied, its replay was a dupe for good: a
2145    /// transaction that admits nothing ended the chain at the predecessor
2146    /// (#559), and one the manager admits WITHOUT its coin (the pf head
2147    /// manager over its own head state, E1D) was inserted, and the
2148    /// predecessor, landing later, stood unspent beside the tip.
2149    ///
2150    /// "Has not landed" is known two ways: this engine saw it fault
2151    /// (`not_landed`, one invocation, every door), or the store says so
2152    /// ([`Engine::unlanded_predecessor`], any invocation, never at a GASP
2153    /// finalize submit: its graph passed the anchor check, every parent
2154    /// inside the graph was submitted just before it and the sequence stops
2155    /// at the first that does not land; the second delta fold of 2026-10-07,
2156    /// M2). For a successor that admits something, both look only at the
2157    /// inputs the manager NAMES as its history (`identify_needed_inputs`
2158    /// over the subject-named BEEF; the lens fold, L1): a manager that
2159    /// names nothing (all 16 of this workspace) asks nothing there, as in
2160    /// the reference. A successor that admits NOTHING is asked of every
2161    /// input whatever its manager names (D17 (a)), so under such a manager
2162    /// it still waits for, or lands first, a carried body that spends a
2163    /// coin the topic holds: there the store differs from the reference's
2164    /// (the E1D delta fold, L3).
2165    ///
2166    /// The door lands a carried predecessor FIRST (the lens fold, M1; the
2167    /// GASP walk's rule, at the door): when the question names an unlanded
2168    /// predecessor whose body the BEEF carries, that body is submitted on
2169    /// its own to this topic ([`Engine::land_carried`]), under the submit's
2170    /// read allowance, and once it lands the topic is judged again
2171    /// ([`Engine::validate_topic`]) and asked again. The re-judgement's
2172    /// reads (the applied row, one coin per input) are charged to that
2173    /// allowance BEFORE the landing (the E1D delta fold, M1): a landing whose
2174    /// re-judgement would not fit is not made, and the topic waits. So one
2175    /// submit reads at most its own validation and writes plus
2176    /// [`PREDECESSOR_READS_PER_SUBMIT`], landings (on their clean path) and
2177    /// re-judgements included; a faulted landing's read-backs and undo, and
2178    /// a deep delete's walk of retained history, are not charged.
2179    /// Nothing is landed into a topic whose manager or a lookup
2180    /// service reads off-chain values (the delta fold, L1: the landing has
2181    /// none to give, and the predecessor's own submit would be a dupe): the
2182    /// topic waits for that submit. The first predecessor that does not land
2183    /// ends it: the topic waits. A predecessor whose body the BEEF does not
2184    /// carry is never landed here: its successor converges only when it
2185    /// lands (its own replay, a GASP peer, a resubmit).
2186    #[allow(clippy::too_many_arguments)]
2187    async fn successors_waiting(
2188        &self,
2189        tx: &Transaction,
2190        txid: &str,
2191        subject_named_beef: &[u8],
2192        validations: &mut [TopicValidation],
2193        steak: &mut Steak,
2194        off_chain_values: Option<&[u8]>,
2195        mode: SubmitMode,
2196        bound: Option<&CallBound>,
2197        finalize: bool,
2198        lands: bool,
2199        blocked: &mut HashMap<String, String>,
2200        question_reads: &mut usize,
2201        landed: &mut Vec<(String, String)>,
2202        landing: Landing,
2203        landed_breaches: &mut Vec<WalkCouldNotRun>,
2204    ) -> HashMap<String, String> {
2205        let mut waits = HashMap::new();
2206        let spent: Vec<(String, u32)> = tx
2207            .inputs
2208            .iter()
2209            .filter_map(|input| {
2210                let source = input.get_source_txid().ok()?;
2211                (!source.is_empty()).then_some((source, input.source_output_index))
2212            })
2213            .collect();
2214        if spent.is_empty() {
2215            return waits;
2216        }
2217        let found_no_coin = |v: &TopicValidation| {
2218            !v.is_dupe && !v.failed && v.read_fault.is_none() && v.previous_outputs.is_empty()
2219        };
2220        for v in validations.iter_mut() {
2221            if !found_no_coin(v) {
2222                continue;
2223            }
2224            let Some(manager) = self.managers.get(&v.topic) else {
2225                continue;
2226            };
2227            let topic = v.topic.clone();
2228            // The manager's word on the subject's history, asked at most
2229            // once per topic: up front for a successor that admits, by the
2230            // question when it needs it for one that does not.
2231            let mut subject_names: Option<HashSet<String>> = None;
2232            loop {
2233                let admits = !v.admittance.outputs_to_admit.is_empty();
2234                if admits && subject_names.is_none() {
2235                    subject_names = Some(
2236                        self.named_history(manager.as_ref(), subject_named_beef, bound)
2237                            .await,
2238                    );
2239                }
2240                let remembered = spent.iter().find(|(source, vout)| {
2241                    (!admits
2242                        || subject_names
2243                            .as_ref()
2244                            .is_some_and(|named| named.contains(&format!("{source}.{vout}"))))
2245                        && self
2246                            .not_landed
2247                            .borrow()
2248                            .contains(&(source.clone(), topic.clone()))
2249                });
2250                if let Some((predecessor, _)) = remembered {
2251                    waits.insert(topic.clone(), format!("{predecessor} has not landed"));
2252                    break;
2253                }
2254                if finalize {
2255                    break;
2256                }
2257                let Some(not_now) = self
2258                    .unlanded_predecessor(
2259                        tx,
2260                        txid,
2261                        subject_named_beef,
2262                        &topic,
2263                        mode,
2264                        admits,
2265                        &mut subject_names,
2266                        question_reads,
2267                        bound,
2268                    )
2269                    .await
2270                else {
2271                    break;
2272                };
2273                let Some(carried) = not_now.carried else {
2274                    waits.insert(topic.clone(), not_now.why);
2275                    break;
2276                };
2277                if !lands {
2278                    blocked.insert(topic.clone(), carried);
2279                    waits.insert(topic.clone(), not_now.why);
2280                    break;
2281                }
2282                if !self.lands_without_values(&topic) {
2283                    waits.insert(
2284                        topic.clone(),
2285                        format!(
2286                            "{}; 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",
2287                            not_now.why
2288                        ),
2289                    );
2290                    break;
2291                }
2292                // The re-judgement after the landing, charged before it.
2293                let rejudge = 1 + spent.len();
2294                if *question_reads + rejudge > PREDECESSOR_READS_PER_SUBMIT {
2295                    waits.insert(
2296                        topic.clone(),
2297                        format!(
2298                            "{}; its landing and the re-judgement after it do not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads",
2299                            not_now.why
2300                        ),
2301                    );
2302                    break;
2303                }
2304                *question_reads += rejudge;
2305                match self
2306                    .land_carried(
2307                        subject_named_beef,
2308                        txid,
2309                        &carried,
2310                        &topic,
2311                        mode,
2312                        bound,
2313                        question_reads,
2314                        landed,
2315                        landing,
2316                        landed_breaches,
2317                    )
2318                    .await
2319                {
2320                    Ok(()) => {
2321                        *v = self
2322                            .validate_topic(
2323                                tx,
2324                                txid,
2325                                &topic,
2326                                off_chain_values,
2327                                mode,
2328                                &TopicAdmittanceContext::default(),
2329                                bound,
2330                            )
2331                            .await;
2332                        steak.insert(topic.clone(), v.admittance.clone());
2333                        if !found_no_coin(v) {
2334                            break;
2335                        }
2336                    }
2337                    Err(did_not_land) => {
2338                        // No re-judgement follows: its reads were not made.
2339                        *question_reads -= rejudge;
2340                        waits.insert(
2341                            topic.clone(),
2342                            format!(
2343                                "{}; submitted first from the BEEF, it did not land ({did_not_land})",
2344                                not_now.why
2345                            ),
2346                        );
2347                        break;
2348                    }
2349                }
2350            }
2351            // A subject that already HOLDS an output in the topic landed
2352            // there before (the leftover of its own earlier submit whose
2353            // delete was started and faulted, D17 M1: its outputs held, no
2354            // applied row), and that admission was judged with its coin.
2355            // Its replay finishes it and does not wait (the E1D lens fold,
2356            // L4: a proven head spend that names a decoy no peer serves was
2357            // "not now" on every replay, three and a dead letter). One read,
2358            // only on the way to "not now", inside the allowance.
2359            if !finalize
2360                && waits.contains_key(&topic)
2361                && *question_reads < PREDECESSOR_READS_PER_SUBMIT
2362            {
2363                *question_reads += 1;
2364                if let Ok(outputs) = stored(
2365                    bound,
2366                    self.storage.find_outputs_for_transaction(txid, false),
2367                )
2368                .await
2369                {
2370                    if outputs.iter().any(|o| o.topic == topic) {
2371                        info!("topic {topic}: {txid} holds its outputs already; its replay finishes it");
2372                        waits.remove(&topic);
2373                        blocked.remove(&topic);
2374                    }
2375                }
2376            }
2377        }
2378        waits
2379    }
2380
2381    /// Land an unlanded predecessor whose body the successor's BEEF carries
2382    /// (lane E1D's lens fold, M1): submit it on its own to `topic`, its
2383    /// atomic BEEF out of the successor's, no off-chain values (the caller
2384    /// lands nothing into a topic that reads them; the delta fold, L1),
2385    /// never a broadcast (`current-tx` is submitted as `historical-tx`: the
2386    /// successor's BEEF carries it to the network).
2387    ///
2388    /// Each body is WALKED once in this submit (the delta fold, L4): the
2389    /// successor's own SPV walk (`subject`, the same mode, tracker and
2390    /// switch) already passed over every body it reaches without crossing a
2391    /// proven one, so those are not walked again; a body reached only
2392    /// through a proven one (which the successor's walk does not descend)
2393    /// gets its own walk, trusting what this submit already walked, and so
2394    /// does nothing twice. A walk that refuses a body ends the landing.
2395    ///
2396    /// A predecessor whose own submit waits on a carried predecessor of ITS
2397    /// own goes on a stack under it: ancestors first, the walk's rule at
2398    /// the door. Its manager was asked only a DRY RUN then and nothing was
2399    /// written; once the one under it landed it is submitted again and
2400    /// admitted for real, once ([`Engine::submit_counted`]). Each submit's
2401    /// reads on its clean path (its applied row, one coin per input, and
2402    /// its writes' one read of each coin it spends) are charged to the
2403    /// submit's allowance before it starts, and its predecessor
2404    /// question shares that allowance, so the landings of one submit stop
2405    /// at [`PREDECESSOR_READS_PER_SUBMIT`] reads, however deep the chain.
2406    /// Each body that lands is pushed on `landed` (the report's
2407    /// `landed_predecessors`, for the caller's admission guard; the delta
2408    /// fold, L2). Each body is asked of the caller's landing guard first,
2409    /// once ([`Engine::set_landing_guard`]; the delta-2 fold, L2): a refusal
2410    /// ends the landing before that body is submitted.
2411    /// `Ok` once `predecessor` LANDED in `topic` (recorded there,
2412    /// now or before); otherwise why not, at the first that did not.
2413    #[allow(clippy::too_many_arguments)]
2414    async fn land_carried(
2415        &self,
2416        beef_bytes: &[u8],
2417        subject: &str,
2418        predecessor: &str,
2419        topic: &str,
2420        mode: SubmitMode,
2421        bound: Option<&CallBound>,
2422        question_reads: &mut usize,
2423        landed: &mut Vec<(String, String)>,
2424        landing: Landing,
2425        landed_breaches: &mut Vec<WalkCouldNotRun>,
2426    ) -> Result<(), String> {
2427        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
2428            .map_err(|e| format!("the BEEF could not be read again ({e})"))?;
2429        let mode = match mode {
2430            SubmitMode::CurrentTx => SubmitMode::HistoricalTx,
2431            other => other,
2432        };
2433        let walks = mode != SubmitMode::HistoricalTxNoSpv;
2434        // The trust set is what a walk that RAN covered (the E592 lens fold,
2435        // M1): a subject whose walk breached covered nothing, so each carried
2436        // predecessor is walked by its own submit (and its own breach is
2437        // reported, `landed_breaches`), never trusted by a breached walk.
2438        let mut walked: HashSet<String> = if walks && landing.subject_walk_ran {
2439            walk_cover(&beef, subject)
2440        } else {
2441            HashSet::new()
2442        };
2443        let mut stack = vec![predecessor.to_string()];
2444        let mut guarded: HashSet<String> = HashSet::new();
2445        while let Some(next) = stack.last().cloned() {
2446            let body = beef
2447                .find_atomic_transaction(&next)
2448                .ok_or_else(|| format!("{next}: its body is not in the BEEF"))?;
2449            // The caller's admission predicate, before anything of this body
2450            // is submitted (the delta-2 fold, L2).
2451            if let Some(guard) = &self.landing_guard {
2452                if guarded.insert(next.clone()) {
2453                    bounded(bound, guard(&next, topic))
2454                        .await
2455                        .and_then(|answer| answer)
2456                        .map_err(|why| format!("{next}: refused by the landing guard ({why})"))?;
2457                }
2458            }
2459            // Its validation (the applied row, one coin per input) and its
2460            // writes' one read of each coin it spends.
2461            let cost = 1 + 2 * body.inputs.len();
2462            if *question_reads + cost > PREDECESSOR_READS_PER_SUBMIT {
2463                return Err(format!(
2464                    "{next}: its submit does not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2465                ));
2466            }
2467            *question_reads += cost;
2468            let atomic = body
2469                .to_atomic_beef(true)
2470                .map_err(|e| format!("{next}: its BEEF could not be built ({e})"))?;
2471            let tagged = TaggedBEEF::new(atomic, vec![topic.to_string()]);
2472            let (_, report, blocked) = match self
2473                .submit_counted(
2474                    &tagged,
2475                    mode,
2476                    bound,
2477                    false,
2478                    Some(&walked),
2479                    question_reads,
2480                    landing.policy,
2481                )
2482                .await
2483            {
2484                Ok(counted) => counted,
2485                // Its walk could not run and nothing stands in for it: it does
2486                // not land now, and the successor waits (counted, L3).
2487                Err(EngineError::WalkCouldNotRun(stop)) => {
2488                    let why = format!("{next}: {}", EngineError::WalkCouldNotRun(stop.clone()));
2489                    landed_breaches.push(stop);
2490                    return Err(why);
2491                }
2492                Err(e) => return Err(format!("{next}: {e}")),
2493            };
2494            // Its walk passed, so did that of every body it reaches; a walk
2495            // that went on past a breach (`NetworkAccepted`) passed nothing.
2496            if let Some(stop) = &report.walk_could_not_run {
2497                landed_breaches.push(stop.clone());
2498            } else if walks && !walked.contains(&next) {
2499                walked.extend(walk_cover(&beef, &next));
2500            }
2501            let landed_now = report.applied_topics.iter().any(|t| t == topic);
2502            if report.is_durable()
2503                && (landed_now || report.deduped_topics.iter().any(|t| t == topic))
2504            {
2505                info!("topic {topic}: {next} landed first from its successor's BEEF");
2506                if landed_now {
2507                    landed.push((next.clone(), topic.to_string()));
2508                }
2509                stack.pop();
2510                continue;
2511            }
2512            match blocked.get(topic) {
2513                Some(under) if !stack.contains(under) => stack.push(under.clone()),
2514                _ if report.is_durable() => return Err(format!("{next}: the manager failed it")),
2515                _ => return Err(format!("{next}: {}", report.summary())),
2516            }
2517        }
2518        Ok(())
2519    }
2520
2521    /// A topic of a submit that is NOT recorded as applied: logged, and
2522    /// remembered for the successor rule (`not_landed`).
2523    fn hold_unapplied(&self, txid: &str, topic: &str, why: &str) {
2524        warn!("topic {topic}: {txid}: {why}; applied_transactions NOT recorded (bsv-low #559)");
2525        self.not_landed
2526            .borrow_mut()
2527            .insert((txid.to_string(), topic.to_string()));
2528    }
2529
2530    /// Take out again the outputs a faulted submit inserted (bsv-low #559,
2531    /// the lens fold of 2026-10-07, F1), so that nothing of a transaction
2532    /// that did not land is held. Only a row nobody has touched since is
2533    /// deleted: unspent and consumed by nothing. A plain delete, not the
2534    /// deep one: the row's retained inputs were not pointed at it yet. A
2535    /// fault here is reported (`undo_insert_output`) and the row stays: the
2536    /// limit of an undo made of separate calls.
2537    async fn undo_inserts(
2538        &self,
2539        txid: &str,
2540        topic: &str,
2541        output_indices: &[u32],
2542        bound: Option<&CallBound>,
2543        report: &mut MutationReport,
2544    ) {
2545        for &output_index in output_indices {
2546            let row = stored(
2547                bound,
2548                self.storage
2549                    .find_output(txid, output_index, Some(topic), None, false),
2550            )
2551            .await;
2552            let undone = match row {
2553                Ok(Some(row)) if !row.spent && row.consumed_by.is_empty() => {
2554                    stored(bound, self.storage.delete_output(txid, output_index, topic)).await
2555                }
2556                Ok(_) => Ok(()),
2557                Err(e) => Err(e),
2558            };
2559            if let Err(e) = undone {
2560                error!("Error taking {txid}.{output_index} out of topic {topic} again: {e}");
2561                report.fault(topic, "undo_insert_output", e.to_string());
2562            }
2563        }
2564    }
2565
2566    /// The store's own answer to "has a predecessor of `tx` not landed?"
2567    /// (bsv-low #559, the lens fold of 2026-10-07, F3; lane E1D, bsv-low
2568    /// #575, and its lens fold of 2026-10-08), for a transaction that found
2569    /// no previous coin. The engine's memory of a faulted submit
2570    /// (`not_landed`) dies with the invocation; this does not. A transaction
2571    /// HAS LANDED in `topic` when it has an applied row there or an output
2572    /// held there; anything else is not known to have landed, and it is a
2573    /// PREDECESSOR of `tx` when one of these says so:
2574    ///
2575    /// - its body is in the BEEF and it spends a coin the topic HOLDS (or,
2576    ///   one step up, a coin of another such transaction): a spend of this
2577    ///   topic the store has not taken yet (#559);
2578    /// - its body is in the BEEF, it spends no coin the topic holds, the
2579    ///   manager NAMES the outpoint the walk spends from it as overlay
2580    ///   history (`identify_needed_inputs` over the BEEF of the transaction
2581    ///   that spends it, `tx` or a body the walk reached), and the manager,
2582    ///   asked of that body in a dry run with no coins
2583    ///   (`TopicAdmittanceContext::DRY_RUN`, the submit's mode, no off-chain
2584    ///   values), would admit that named output: an OPENER that has not
2585    ///   landed (E1D, the cure D17 named). Only a NAMED output is dry-run
2586    ///   (the lens fold, H1): with every unheld body dry-run, a manager that
2587    ///   admits on output shape (all 16 of this workspace) made a spend of a
2588    ///   shape-admissible output this node never held (a revocation of an
2589    ///   ad it never saw; a stranger's own few-sat SHIP output) "not now" on
2590    ///   every presentation, three replays and a dead letter each, where the
2591    ///   reference records it. A manager `Err` in the dry run is "not now"
2592    ///   (the manager's contract at the anchor replay, D15), except its
2593    ///   typed refusal `NoAdmissibleOutputs`, which is "not admitted";
2594    /// - its body is NOT in the BEEF (a PROVEN successor carries none) and
2595    ///   the manager names the outpoint `tx` spends from it as overlay
2596    ///   history (D13's word; an `Err` there names nothing, as in the walk).
2597    ///
2598    /// The manager is asked over `beef_bytes`, the SUBJECT-NAMED BEEF of the
2599    /// submit (BRC-95 atomic, the body the lookup services get; the lens
2600    /// fold, M2): over the submitted bytes a manager that parses
2601    /// `from_beef(_, None)` took the wire-LAST transaction of a non-atomic
2602    /// BEEF, named an ancestor's inputs, and the question asked nothing.
2603    ///
2604    /// `admits` says which inputs of `tx` start the walk. A transaction that
2605    /// admits nothing (D17's class) starts from every input. One that admits
2606    /// WITHOUT its coin (E1D) starts only from the inputs the manager names:
2607    /// a manager that admits a successor with no coin has said the coin does
2608    /// not decide the admission, so only its own word makes an input a
2609    /// predecessor. Every manager of this workspace names nothing: an
2610    /// admitting successor is then asked nothing, as in the reference, and
2611    /// so is a spend of an unheld output nobody names (the lens fold, H1);
2612    /// but a successor that admits nothing still finds case (a), a carried
2613    /// body that spends a coin the topic holds, and waits for it or has it
2614    /// landed first, where the reference records the successor and never
2615    /// admits that body (the E1D delta fold, L3). A named input whose body
2616    /// the BEEF carries is still walked and dry-run, so a named decoy the
2617    /// manager would not admit is no predecessor.
2618    ///
2619    /// The answer carries the predecessor when its body is in the BEEF (the
2620    /// first two cases): the door lands it first ([`Engine::successors_waiting`]).
2621    ///
2622    /// The limits, stated. It cannot tell a faulted predecessor from one
2623    /// nobody submitted yet: a successor is "not now" in both until the
2624    /// predecessor lands (the door lands a carried one itself). A named
2625    /// input whose body is absent and which never lands (a decoy no one
2626    /// submits, beside a real predecessor that landed and holds no coin)
2627    /// keeps its successor "not now" at the door; the GASP walk prunes such
2628    /// an input instead (D14). Deeper than the successor's own inputs, an
2629    /// absent body ends the walk as before.
2630    ///
2631    /// "Landed" needs a clean answer (the delta fold of 2026-10-07, M1 and
2632    /// M2): a read that faults, or the question running out of its reads
2633    /// (the 16 of a topic or the 256 of the submit), answers "not now" too,
2634    /// naming the transaction it could not settle. Recording the successor
2635    /// there made its replay a dupe and stopped the chain behind it for
2636    /// good; a retried no-op is the lesser cost.
2637    ///
2638    /// What the bound counts (the second delta fold of 2026-10-07, M2): the
2639    /// reads spent on a body that is NEITHER proven in the BEEF NOR found
2640    /// landed, [`PREDECESSOR_READS`] of them per topic. A body the BEEF
2641    /// proves, and one whose applied row or held output answers "landed", is
2642    /// read and costs nothing against that bound: counted, they made a
2643    /// transaction that admits nothing over 17 landed parents, over six
2644    /// proven parents the topic never saw, or over one proven parent with 15
2645    /// inputs "not now" on every submit, with no unlanded predecessor
2646    /// anywhere. A landed body still needs ROOM under it: each read of an
2647    /// unproven body is tested before it is made, so with 15 counted reads
2648    /// spent one that landed by a held output (no applied row) is refused at
2649    /// its second read, and with 16 spent any unproven body at its first.
2650    ///
2651    /// "Proven" is the BEEF's word here: a body that carries a merkle path
2652    /// (a bump index) is counted as proven for the read bound ONLY. No proof
2653    /// is checked at this question; the SPV walk of the submit and the GASP
2654    /// anchor check are where proofs are checked, and under
2655    /// `historical-tx-no-spv` or the scripts-only walk nothing checked this
2656    /// one, so the 16 are the caller's to switch off. Nothing is recorded on
2657    /// that word (a proven body is walked like any other), and the allowance
2658    /// below does not ask it.
2659    ///
2660    /// The hard allowance (the third delta fold of 2026-10-07, M1):
2661    /// `question_reads`, owned by the submit, counts EVERY read of the
2662    /// question, those of proven and landed bodies included, over every
2663    /// topic of the submit, and stops it at
2664    /// [`PREDECESSOR_READS_PER_SUBMIT`]; the 16 stay inside it. A later
2665    /// topic's question starts from what the earlier ones left.
2666    ///
2667    /// The costs, stated. A transaction that admits nothing, found no coin
2668    /// and carries more UNPROVEN, UNLANDED bodies than 16 reads settle (five
2669    /// single-input ancestors, fewer with more inputs), or a BEEF whose
2670    /// question needs more than 256 reads over the submit's topics (a
2671    /// landed body costs one read by its applied row or two by a held
2672    /// output, a proven, unlanded one two and one per input), is never
2673    /// recorded, where the reference records it: each submit of it is a
2674    /// fault and a replay. The manager CPU it adds: one names call per
2675    /// spender a dry run needs (the subject's, and one per walked body with
2676    /// an unheld source), and at most one dry run per body the reads
2677    /// reached; each candidate is built out of the BEEF parsed once (the
2678    /// lens fold, L2).
2679    ///
2680    /// Returns why the submit must wait, for the report.
2681    #[allow(clippy::too_many_arguments)]
2682    async fn unlanded_predecessor(
2683        &self,
2684        tx: &Transaction,
2685        txid: &str,
2686        beef_bytes: &[u8],
2687        topic: &str,
2688        mode: SubmitMode,
2689        admits: bool,
2690        subject_names: &mut Option<HashSet<String>>,
2691        question_reads: &mut usize,
2692        bound: Option<&CallBound>,
2693    ) -> Option<NotNow> {
2694        let manager = self.managers.get(topic)?;
2695        let wait = |why: String| Some(NotNow { why, carried: None });
2696        // The outpoints `tx` spends, as `txid.vout`.
2697        let starts: Vec<(String, u32)> = tx
2698            .inputs
2699            .iter()
2700            .filter_map(|input| {
2701                let source = input.get_source_txid().ok()?;
2702                (!source.is_empty()).then_some((source, input.source_output_index))
2703            })
2704            .collect();
2705        // The outputs of each candidate that the walk spends, and who spends
2706        // each: a dry run asks only of an output its spender names.
2707        let mut spent_of: HashMap<String, HashSet<u32>> = HashMap::new();
2708        let mut spenders: HashMap<String, Vec<(String, u32)>> = HashMap::new();
2709        let mut ask: Vec<String> = Vec::new();
2710        for (source, vout) in &starts {
2711            if admits
2712                && subject_names
2713                    .as_ref()
2714                    .is_some_and(|named| !named.contains(&format!("{source}.{vout}")))
2715            {
2716                continue;
2717            }
2718            spent_of.entry(source.clone()).or_default().insert(*vout);
2719            spenders
2720                .entry(source.clone())
2721                .or_default()
2722                .push((txid.to_string(), *vout));
2723            ask.push(source.clone());
2724        }
2725        // Nothing to ask (a transaction that admits and names nothing, every
2726        // manager of this workspace): the BEEF is not even parsed.
2727        if ask.is_empty() {
2728            return None;
2729        }
2730        let direct: HashSet<String> = ask.iter().cloned().collect();
2731        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS).ok()?;
2732        // Each body the BEEF carries, and whether the BEEF says it is
2733        // proven (a bump index: its word, no proof is checked here).
2734        let bodies: HashMap<String, (&Transaction, bool)> = beef
2735            .txs
2736            .iter()
2737            .filter_map(|btx| {
2738                btx.tx()
2739                    .map(|body| (btx.txid(), (body, btx.bump_index().is_some())))
2740            })
2741            .collect();
2742        // The manager's names over each walked body other than `tx`.
2743        let mut names_over: HashMap<String, HashSet<String>> = HashMap::new();
2744        // The reads spent on bodies neither proven nor found landed.
2745        let mut reads = 0usize;
2746        let mut asked: HashSet<String> = HashSet::new();
2747        while let Some(candidate) = ask.pop() {
2748            if candidate.is_empty() || !asked.insert(candidate.clone()) {
2749                continue;
2750            }
2751            let spent_outputs = spent_of.get(&candidate).cloned().unwrap_or_default();
2752            // An absent body is asked of the store only when it is one of
2753            // `tx`'s own inputs and the manager names it (E1D).
2754            let body = bodies.get(&candidate).copied();
2755            if body.is_none() {
2756                if !direct.contains(&candidate) {
2757                    continue;
2758                }
2759                if subject_names.is_none() {
2760                    *subject_names = Some(
2761                        self.named_history(manager.as_ref(), beef_bytes, bound)
2762                            .await,
2763                    );
2764                }
2765                let is_named = subject_names.as_ref().is_some_and(|named| {
2766                    spent_outputs
2767                        .iter()
2768                        .any(|vout| named.contains(&format!("{candidate}.{vout}")))
2769                });
2770                if !is_named {
2771                    continue;
2772                }
2773            }
2774            let proven = body.is_some_and(|(_, proven)| proven);
2775            // This candidate's own reads: they join `reads` once it is
2776            // found not landed, and never if the BEEF proves it.
2777            let mut spent = 0usize;
2778            let out_of = |spent: usize| !proven && reads + spent >= PREDECESSOR_READS;
2779            let out_of_reads = || {
2780                wait(format!(
2781                    "{candidate} is not known to have landed: the question ran out of its {PREDECESSOR_READS} reads"
2782                ))
2783            };
2784            // The submit's allowance, over every read: proven or landed,
2785            // this topic or an earlier one.
2786            let out_of_allowance = || {
2787                wait(format!(
2788                    "{candidate} is not known to have landed: the question ran out of the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2789                ))
2790            };
2791            let unread = |e: StorageError| {
2792                wait(format!(
2793                    "{candidate} is not known to have landed: the store could not say ({e})"
2794                ))
2795            };
2796            let carried = |why: String| {
2797                Some(NotNow {
2798                    why,
2799                    carried: Some(candidate.clone()),
2800                })
2801            };
2802            let record = AppliedTransaction {
2803                txid: candidate.clone(),
2804                topic: topic.to_string(),
2805            };
2806            // An applied row, or an output held, ends the question for this
2807            // transaction: it landed.
2808            if out_of(spent) {
2809                return out_of_reads();
2810            }
2811            if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2812                return out_of_allowance();
2813            }
2814            spent += 1;
2815            *question_reads += 1;
2816            match stored(bound, self.storage.does_applied_transaction_exist(&record)).await {
2817                Ok(false) => {}
2818                Ok(true) => continue,
2819                Err(e) => return unread(e),
2820            }
2821            if out_of(spent) {
2822                return out_of_reads();
2823            }
2824            if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2825                return out_of_allowance();
2826            }
2827            spent += 1;
2828            *question_reads += 1;
2829            match stored(
2830                bound,
2831                self.storage.find_outputs_for_transaction(&candidate, false),
2832            )
2833            .await
2834            {
2835                Ok(outputs) if outputs.iter().all(|o| o.topic != topic) => {}
2836                Ok(_) => continue,
2837                Err(e) => return unread(e),
2838            }
2839            // Not landed. With no body to walk, the manager's word is the
2840            // whole answer (E1D).
2841            let Some((body, _)) = body else {
2842                return wait(format!(
2843                    "{candidate} has not landed (the manager names it as history; the BEEF does not carry it)"
2844                ));
2845            };
2846            for input in &body.inputs {
2847                let source = input.get_source_txid().unwrap_or_default();
2848                if source.is_empty() {
2849                    continue;
2850                }
2851                if out_of(spent) {
2852                    return out_of_reads();
2853                }
2854                if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2855                    return out_of_allowance();
2856                }
2857                spent += 1;
2858                *question_reads += 1;
2859                let coin = stored(
2860                    bound,
2861                    self.storage.find_output(
2862                        &source,
2863                        input.source_output_index,
2864                        Some(topic),
2865                        None,
2866                        false,
2867                    ),
2868                )
2869                .await;
2870                match coin {
2871                    Ok(Some(_)) => return carried(format!("{candidate} has not landed")),
2872                    // Not held: its own transaction may be the one that has
2873                    // not landed.
2874                    Ok(None) => {
2875                        spent_of
2876                            .entry(source.clone())
2877                            .or_default()
2878                            .insert(input.source_output_index);
2879                        spenders
2880                            .entry(source.clone())
2881                            .or_default()
2882                            .push((candidate.clone(), input.source_output_index));
2883                        ask.push(source);
2884                    }
2885                    Err(e) => return unread(e),
2886                }
2887            }
2888            // It spends no coin the topic holds. Does a spender NAME an
2889            // output the walk spends of it (the lens fold, H1)? A names call
2890            // per spender, no store read.
2891            let mut named_outputs: HashSet<u32> = HashSet::new();
2892            for (spender, vout) in spenders.get(&candidate).cloned().unwrap_or_default() {
2893                let names = if spender == txid {
2894                    if subject_names.is_none() {
2895                        *subject_names = Some(
2896                            self.named_history(manager.as_ref(), beef_bytes, bound)
2897                                .await,
2898                        );
2899                    }
2900                    subject_names.as_ref()
2901                } else {
2902                    if !names_over.contains_key(&spender) {
2903                        let names = match beef
2904                            .find_atomic_transaction(&spender)
2905                            .and_then(|spender| spender.to_atomic_beef(true).ok())
2906                        {
2907                            Some(spender_beef) => {
2908                                self.named_history(manager.as_ref(), &spender_beef, bound)
2909                                    .await
2910                            }
2911                            None => HashSet::new(),
2912                        };
2913                        names_over.insert(spender.clone(), names);
2914                    }
2915                    names_over.get(&spender)
2916                };
2917                if names.is_some_and(|named| named.contains(&format!("{candidate}.{vout}"))) {
2918                    named_outputs.insert(vout);
2919                }
2920            }
2921            if !named_outputs.is_empty() {
2922                // Would the manager admit a named output of it with no coins
2923                // (E1D, the opener)? A dry run over the candidate built out
2924                // of the BEEF parsed above (its proof and, unproven, its
2925                // sources linked): no store read, nothing counted.
2926                let judged = beef
2927                    .find_atomic_transaction(&candidate)
2928                    .unwrap_or_else(|| body.clone());
2929                let verdict = bounded(
2930                    bound,
2931                    manager.identify_admissible_outputs(
2932                        &judged,
2933                        &[],
2934                        None,
2935                        mode,
2936                        &TopicAdmittanceContext::DRY_RUN,
2937                    ),
2938                )
2939                .await;
2940                match verdict {
2941                    Ok(Ok(admittance)) => {
2942                        if admittance
2943                            .outputs_to_admit
2944                            .iter()
2945                            .any(|vout| named_outputs.contains(vout))
2946                        {
2947                            return carried(format!(
2948                                "{candidate} has not landed (the manager names the output spent from it and would admit it with no coin: an opener)"
2949                            ));
2950                        }
2951                    }
2952                    Ok(Err(TopicManagerError::NoAdmissibleOutputs(_))) => {}
2953                    Ok(Err(e)) => {
2954                        return wait(format!(
2955                            "{candidate} is not known to have landed: the manager could not judge it ({e})"
2956                        ))
2957                    }
2958                    Err(no_answer) => {
2959                        return wait(format!(
2960                            "{candidate} is not known to have landed: the manager did not answer ({no_answer})"
2961                        ))
2962                    }
2963                }
2964            }
2965            if !proven {
2966                reads += spent;
2967            }
2968        }
2969        None
2970    }
2971
2972    /// The outpoints (`txid.vout`) the topic manager NAMES as overlay
2973    /// history of the transaction `beef_bytes` names (`identify_needed_inputs`,
2974    /// D13's word; an atomic BEEF, so a manager that parses
2975    /// `from_beef(_, None)` reads that transaction). An `Err` or no answer
2976    /// names nothing, as the GASP walk treats it (logged).
2977    async fn named_history(
2978        &self,
2979        manager: &dyn TopicManager,
2980        beef_bytes: &[u8],
2981        bound: Option<&CallBound>,
2982    ) -> HashSet<String> {
2983        match bounded(bound, manager.identify_needed_inputs(beef_bytes, None)).await {
2984            Ok(Ok(named)) => named.iter().map(Outpoint::to_graph_id).collect(),
2985            Ok(Err(e)) => {
2986                warn!("the predecessor question: the manager could not name its inputs: {e}");
2987                HashSet::new()
2988            }
2989            Err(no_answer) => {
2990                warn!("the predecessor question: the manager did not name its inputs: {no_answer}");
2991                HashSet::new()
2992            }
2993        }
2994    }
2995
2996    /// The reference's `tx.verify(chainTracker)` on submit: every unproven
2997    /// transaction from the subject down has every input's script executed
2998    /// against its source output and obeys the value rule; a proven one is
2999    /// not descended, and its BUMP's root is checked against the chain
3000    /// tracker (computed and accepted with none).
3001    ///
3002    /// Since bsv-low #592 (the owner's ruling 3a of 2026-10-10) the scripts
3003    /// are walked by the DOOR's stream walk ([`crate::script_door`]) under
3004    /// `budget`, the engine's [`DoorBudget`]: one walker, the same estimates
3005    /// charged from the bytes before anything is parsed or run. Before it this
3006    /// was a walk of its own over a hydrated `Beef` with no memory charge and
3007    /// no interpreter limit: 219.7 MB natively on a 1.9 MB push-only unproven
3008    /// parent, an isolate kill on Workers, met by every `historical-tx` replay
3009    /// of the queue. The roots are then asked for the proven transactions the
3010    /// walk reached, in its order, once per BUMP.
3011    ///
3012    /// `Ok(Some(_))` is a walk that COULD NOT RUN within the budget, never a
3013    /// refusal (below, at the match). The interpreter's refusal and a
3014    /// structural fault are what they were: `ScriptVerificationFailed`, and
3015    /// `SpvError` in the same words.
3016    ///
3017    /// The tracker is an ARGUMENT, not `self`'s: the GASP anchor check
3018    /// ([`verify_spv_like_the_reference`]) runs this same walk from
3019    /// `OverlayGASPStorage`, which borrows the engine's tracker.
3020    ///
3021    /// Why not bsv-rs `Transaction::verify`: it walks `source_transaction`
3022    /// links that `from_beef` builds by CLONING, once per input. Two inputs
3023    /// sourcing the same unproven parent (a covenant head output plus that
3024    /// spend's own change, the shape of every second head spend of a wallet)
3025    /// leave the second clone bare ("Input 0 has no source transaction"), and
3026    /// re-linking every clone materializes the ancestry EXPONENTIALLY along a
3027    /// diamond chain: a 12-deep unmined chain of head spends took a Worker past
3028    /// its memory on beta (zanaadu, 2026-09-08). The stream walk reads each
3029    /// transaction once, in place, keyed by txid.
3030    async fn verify_beef_linear(
3031        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3032        beef_bytes: &[u8],
3033        subject_txid: &str,
3034        trusted: &HashSet<String>,
3035        budget: DoorBudget,
3036    ) -> Result<Option<WalkCouldNotRun>, EngineError> {
3037        // A structural fault is the reference walk's `SpvError`.
3038        let spv =
3039            |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3040
3041        // THE SCRIPTS (bsv-low #592): the door's own stream walk, under the
3042        // engine's budget. One walker (Rule 10): the door and the submit run
3043        // the same function, the same estimates and the same interpreter
3044        // limit; the submit adds the roots below.
3045        let mut trace = crate::script_door::WalkTrace::default();
3046        match crate::script_door::walk_trusting(
3047            beef_bytes,
3048            subject_txid,
3049            budget,
3050            trusted,
3051            &mut trace,
3052        ) {
3053            Ok(_) => {}
3054            // The interpreter's verdict, the reference's refusal: unchanged.
3055            Err(refused @ EngineError::ScriptVerificationFailed { .. }) => return Err(refused),
3056            // A structural fault (a source missing, the value rule, ...):
3057            // the reference walk's `SpvError`, in the same words as before.
3058            Err(EngineError::ScriptWalkInconclusive { reason, .. }) => return Err(spv(reason)),
3059            // The budget, not the transaction: the walk COULD NOT RUN. Not a
3060            // refusal: the bytes are a BEEF the walk was not given the room
3061            // to judge, which says nothing about whether its scripts are
3062            // valid (refusing would refuse a valid BEEF for its size, the
3063            // owner's posture). Not an admission either: the caller's
3064            // [`WalkBreachPolicy`] decides, and only the network's word on
3065            // record lets the submit go on (the E592 lens fold, H1). The roots
3066            // the walk reached are checked first, below.
3067            Err(EngineError::ScriptWalkOverBudget {
3068                at_txid,
3069                subject_judged,
3070                limb,
3071                what,
3072            }) => {
3073                let limb = match limb {
3074                    DoorLimb::Memory => WalkLimb::OverMemory,
3075                    DoorLimb::Work if trace.interpreter_tripped => WalkLimb::InterpreterMemory,
3076                    DoorLimb::Work => WalkLimb::OverWork,
3077                };
3078                // THE ROOTS OF A BREACHED WALK (the E592 lens fold, H1): every
3079                // proven transaction it reached, and every proven source an
3080                // input read, whatever the scripts' verdict. A bad root is
3081                // the `SpvError` it is on a walk that ran: a breach never
3082                // skips a proof the walk relied on. Their cost is a tracker
3083                // question per BUMP, which the budget does not bound and
3084                // does not need to (the clean walk asks the same).
3085                let reached: Vec<String> = trace
3086                    .proven
3087                    .iter()
3088                    .chain(&trace.proven_sources)
3089                    .cloned()
3090                    .collect();
3091                Self::check_reached_roots(chain_tracker, beef_bytes, &reached).await?;
3092                return Ok(Some(WalkCouldNotRun {
3093                    subject_txid: subject_txid.to_string(),
3094                    at_txid,
3095                    subject_judged,
3096                    limb,
3097                    what,
3098                }));
3099            }
3100            Err(other) => return Err(other),
3101        }
3102
3103        // THE ROOTS: every proven transaction the walk reached, in the order
3104        // it reached them, its BUMP's root computed and asked of the tracker
3105        // once per BUMP (NL-6e). With no tracker the roots are computed and
3106        // accepted (`'scripts only'`). The walk trusted them as it went; the
3107        // reference checks them in the same walk, so a body with BOTH a bad
3108        // script and a bad root now names the script.
3109        Self::check_reached_roots(chain_tracker, beef_bytes, &trace.proven).await?;
3110        Ok(None)
3111    }
3112
3113    /// The roots of `reached` (display txids, in order; a repeat or a second
3114    /// transaction of one BUMP asks nothing more), each BUMP's root computed
3115    /// and asked of the tracker once. None reached: the body is not parsed.
3116    async fn check_reached_roots(
3117        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3118        beef_bytes: &[u8],
3119        reached: &[String],
3120    ) -> Result<(), EngineError> {
3121        let spv =
3122            |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3123        if reached.is_empty() {
3124            return Ok(());
3125        }
3126        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
3127            .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3128        let proven_in = proven_index(&beef);
3129        let mut checked: HashSet<usize> = HashSet::new();
3130        for txid in reached {
3131            let Some(&at) = proven_in.get(txid.as_str()) else {
3132                return Err(spv(format!("transaction {txid} is not in the BEEF")));
3133            };
3134            if !checked.insert(at) {
3135                continue; // its BUMP's root is the chain's, asked once
3136            }
3137            let mp = &beef.bumps[at];
3138            let root = mp
3139                .compute_root(Some(txid))
3140                .map_err(|e| spv(format!("invalid merkle path for transaction {txid}: {e}")))?;
3141            if let Some(tracker) = chain_tracker {
3142                match tracker
3143                    .is_valid_root_for_height(&root, mp.block_height)
3144                    .await
3145                {
3146                    Ok(true) => {}
3147                    Ok(false) => {
3148                        return Err(EngineError::SpvError(format!(
3149                            "Invalid merkle path for transaction {txid}: root {root} is not valid for block height {}",
3150                            mp.block_height
3151                        )));
3152                    }
3153                    Err(e) => {
3154                        return Err(EngineError::SpvError(format!(
3155                            "Chain tracker error at height {}: {e}",
3156                            mp.block_height
3157                        )));
3158                    }
3159                }
3160            }
3161        }
3162        Ok(())
3163    }
3164
3165    /// The pre-2026-09-08 SPV block, kept as the
3166    /// [`Engine::set_script_verification`] `false` escape hatch: BEEF
3167    /// structural validity plus every root against the chain tracker, and
3168    /// NOTHING when no tracker is configured. It never executes a script.
3169    ///
3170    /// The structure is the streaming reader's
3171    /// ([`beef_limits::structure_roots`], bsv-rs 0.4.2): one element in hand,
3172    /// each BUMP walked once, linear in its leaves (NL-6e). Until NL-6e it
3173    /// was `Beef::verify_valid`, whose root check at bsv-rs 0.4.0 walked a
3174    /// BUMP once per leaf: one BUMP of 8,192 leaves gave no answer in 120 s
3175    /// (bsv-stack-lean NL-8 W1). The reader reads the wire's order, where
3176    /// `verify_valid` sorted first: an input that names a later transaction
3177    /// is refused here.
3178    async fn verify_spv_structurally(
3179        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3180        beef_bytes: &[u8],
3181    ) -> Result<(), EngineError> {
3182        let Some(chain_tracker) = chain_tracker else {
3183            return Ok(());
3184        };
3185        let roots = beef_limits::structure_roots(beef_bytes)
3186            .map_err(|r| EngineError::SpvError(format!("BEEF structure refused: {r}")))?;
3187        let mut asked: HashSet<(u64, &str)> = HashSet::new();
3188        for (height, root) in &roots {
3189            if !asked.insert((*height, root.as_str())) {
3190                continue;
3191            }
3192            let Ok(height) = u32::try_from(*height) else {
3193                return Err(EngineError::SpvError(format!(
3194                    "Merkle root {root} invalid for block height {height}"
3195                )));
3196            };
3197            match chain_tracker.is_valid_root_for_height(root, height).await {
3198                Ok(true) => {}
3199                Ok(false) => {
3200                    return Err(EngineError::SpvError(format!(
3201                        "Merkle root {root} invalid for block height {height}"
3202                    )));
3203                }
3204                Err(e) => {
3205                    return Err(EngineError::SpvError(format!(
3206                        "Chain tracker error at height {height}: {e}"
3207                    )));
3208                }
3209            }
3210        }
3211        Ok(())
3212    }
3213
3214    /// Phase 1 of ONE topic: the dedup read, the previous coins, the
3215    /// manager's judgement. Read-only. [`Engine::run_validation`] asks it of
3216    /// every topic; the door asks it again of a topic whose predecessor it
3217    /// landed first from the BEEF (lane E1D's lens fold, M1).
3218    #[allow(clippy::too_many_arguments)]
3219    async fn validate_topic(
3220        &self,
3221        tx: &Transaction,
3222        txid: &str,
3223        topic: &str,
3224        off_chain_values: Option<&[u8]>,
3225        mode: SubmitMode,
3226        context: &TopicAdmittanceContext,
3227        bound: Option<&CallBound>,
3228    ) -> TopicValidation {
3229        let tx_record = AppliedTransaction {
3230            txid: txid.to_string(),
3231            topic: topic.to_string(),
3232        };
3233        // A dedup read that FAULTS is the topic's read fault (the delta
3234        // fold of 2026-10-07, H3): nothing is judged and nothing is
3235        // written for the topic, the fault rides the report and the
3236        // replay asks again. The reference fails the topic there
3237        // (`Engine.ts` `submit`: the throw lands in the per-topic catch,
3238        // `failedTopics.add`). Read as "not a dupe", as it was, a
3239        // re-presented transaction the manager admits with no previous
3240        // coin was inserted a second time, unspent, beside the output
3241        // that had spent it, under a durable report.
3242        let is_dupe = match stored(
3243            bound,
3244            self.storage.does_applied_transaction_exist(&tx_record),
3245        )
3246        .await
3247        {
3248            Ok(is_dupe) => is_dupe,
3249            Err(e) => {
3250                error!("Error reading the applied row of {txid} for topic {topic}: {e}");
3251                return TopicValidation {
3252                    topic: topic.to_string(),
3253                    is_dupe: false,
3254                    previous_coins: vec![],
3255                    previous_outputs: vec![],
3256                    admittance: AdmittanceInstructions::default(),
3257                    failed: false,
3258                    read_fault: Some(("does_applied_transaction_exist", e.to_string())),
3259                };
3260            }
3261        };
3262
3263        if is_dupe {
3264            return TopicValidation {
3265                topic: topic.to_string(),
3266                is_dupe: true,
3267                previous_coins: vec![],
3268                previous_outputs: vec![],
3269                admittance: AdmittanceInstructions::default(),
3270                failed: false,
3271                read_fault: None,
3272            };
3273        }
3274
3275        let mut previous_coins: Vec<u32> = Vec::new();
3276        let mut previous_outputs: Vec<Output> = Vec::new();
3277        let mut read_fault: Option<(&'static str, String)> = None;
3278
3279        for (input_idx, input) in tx.inputs.iter().enumerate() {
3280            let source_txid = input.get_source_txid().unwrap_or_default();
3281            if source_txid.is_empty() {
3282                continue;
3283            }
3284            match stored(
3285                bound,
3286                self.storage.find_output(
3287                    &source_txid,
3288                    input.source_output_index,
3289                    Some(topic),
3290                    None,
3291                    false,
3292                ),
3293            )
3294            .await
3295            {
3296                Ok(Some(prev_output)) => {
3297                    previous_coins.push(input_idx as u32);
3298                    previous_outputs.push(prev_output);
3299                }
3300                Ok(None) => {}
3301                Err(e) => {
3302                    // A faulted read is NOT "no previous coin": the
3303                    // spend may consume an admitted output we simply
3304                    // could not read. Validation proceeds (the topic
3305                    // manager decides on what it can see) but the fault
3306                    // rides the report and blocks the applied record.
3307                    error!(
3308                        "Error reading previous output {source_txid}:{} for topic {topic}: {e}",
3309                        input.source_output_index
3310                    );
3311                    if read_fault.is_none() {
3312                        read_fault = Some(("find_output", e.to_string()));
3313                    }
3314                }
3315            }
3316        }
3317
3318        let judged = self
3319            .judge(
3320                tx,
3321                topic,
3322                &previous_coins,
3323                off_chain_values,
3324                mode,
3325                context,
3326                bound,
3327            )
3328            .await;
3329        match judged {
3330            Ok(admittance) => TopicValidation {
3331                topic: topic.to_string(),
3332                is_dupe: false,
3333                previous_coins,
3334                previous_outputs,
3335                admittance,
3336                failed: false,
3337                read_fault: read_fault.clone(),
3338            },
3339            Err(e) => {
3340                error!("Error validating topic {topic} during submit: {e}");
3341                TopicValidation {
3342                    topic: topic.to_string(),
3343                    is_dupe: false,
3344                    previous_coins: vec![],
3345                    previous_outputs: vec![],
3346                    admittance: AdmittanceInstructions::default(),
3347                    failed: true,
3348                    read_fault,
3349                }
3350            }
3351        }
3352    }
3353
3354    /// The topic manager's judgement of `tx` over the previous coins found
3355    /// (input indices), as [`Engine::validate_topic`] asks it; a carried
3356    /// predecessor's submit asks it again for real once its topic does not
3357    /// wait (the E1D delta fold, L4).
3358    #[allow(clippy::too_many_arguments)]
3359    async fn judge(
3360        &self,
3361        tx: &Transaction,
3362        topic: &str,
3363        previous_coins: &[u32],
3364        off_chain_values: Option<&[u8]>,
3365        mode: SubmitMode,
3366        context: &TopicAdmittanceContext,
3367        bound: Option<&CallBound>,
3368    ) -> Result<AdmittanceInstructions, TopicManagerError> {
3369        let manager = &self.managers[topic];
3370        let previous_coin_bytes = previous_coins
3371            .iter()
3372            .flat_map(|i| i.to_le_bytes())
3373            .collect::<Vec<u8>>();
3374        // Under a finalize submit's bound a manager that does not answer
3375        // is a manager that failed: nothing is written for the topic.
3376        bounded(
3377            bound,
3378            manager.identify_admissible_outputs(
3379                // The ONE engine parse of the submitted BEEF, shared by
3380                // every topic on this submit (bsv-low #289 — managers
3381                // used to re-parse the same bytes independently).
3382                tx,
3383                &previous_coin_bytes,
3384                off_chain_values,
3385                mode,
3386                // `dry_run` is `false` on a submit, `true` on a
3387                // validate-only call.
3388                context,
3389            ),
3390        )
3391        .await
3392        .map_err(TopicManagerError::Other)
3393        .and_then(|judged| judged)
3394    }
3395
3396    /// Whether the door may land a carried predecessor into `topic` (the
3397    /// E1D delta fold, L1): neither its manager nor any lookup service
3398    /// reads off-chain values there, which the landing does not have.
3399    fn lands_without_values(&self, topic: &str) -> bool {
3400        self.managers
3401            .get(topic)
3402            .is_some_and(|manager| !manager.reads_off_chain_values())
3403            && self
3404                .lookup_services
3405                .values()
3406                .all(|ls| !ls.reads_off_chain_values(topic))
3407    }
3408
3409    /// Run Phase 1 (topic validation) and Phase 2 (broadcast) without mutating storage.
3410    ///
3411    /// Returns (validations, steak, parsed_tx, txid) so the caller can either
3412    /// stop (validate-only) or proceed with Phase 3 (mutations).
3413    async fn run_validation(
3414        &self,
3415        tagged_beef: &TaggedBEEF,
3416        mode: SubmitMode,
3417        context: &TopicAdmittanceContext,
3418        bound: Option<&CallBound>,
3419        walked: Option<&HashSet<String>>,
3420        policy: WalkBreachPolicy,
3421    ) -> Result<Validated, EngineError> {
3422        // Validate all topics are supported
3423        for topic in &tagged_beef.topics {
3424            if !self.managers.contains_key(topic) {
3425                return Err(EngineError::UnsupportedTopic(topic.clone()));
3426            }
3427        }
3428
3429        // Parse the SUBJECT from the BEEF — order-independent (`subject.rs`,
3430        // loop-2 hardening 2026-09-05): `from_beef(_, None)` takes the last
3431        // tx in wire order, which an SDK-sorted INCOMPLETE ancestry fills
3432        // with a fully-sourced ancestor; the engine then judged a hop, admitted
3433        // nothing and the pot never entered the index while its JOIN mined.
3434        let subject = {
3435            let mut b =
3436                beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
3437                    .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3438            crate::subject::subject_txid_of(&mut b)
3439        };
3440        let tx = beef_limits::transaction_from_beef(
3441            &tagged_beef.beef,
3442            subject.as_deref(),
3443            &beef_limits::ENGINE_BEEF_LIMITS,
3444        )
3445        .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3446        let txid = tx.id();
3447
3448        // SPV verification, skipped ONLY for HistoricalTxNoSpv, exactly the
3449        // reference's `if (mode !== 'historical-tx-no-spv') tx.verify(...)`.
3450        // That mode exists for GASP `finalizeGraph`, whose graphs
3451        // `validateGraphAnchor` already verified, and since bsv-low #551 that
3452        // is TRUE here: `OverlayGASPStorage::validate_graph_anchor` runs this
3453        // same check ([`verify_spv_like_the_reference`], the same tracker and
3454        // the same switch) over the ROOT node's BEEF and replays the graph
3455        // through the topic manager before a single BEEF of it reaches this
3456        // door. Before #551 the anchor check was a no-op and this skip
3457        // admitted a peer's graph on structure alone.
3458        //
3459        // A predecessor the door lands first (`walked`, the E1D delta fold,
3460        // L4) is not walked again when this submit's walks already passed
3461        // over it, and its walk trusts what they passed over.
3462        //
3463        // The walk runs under the engine's budget (bsv-low #592,
3464        // [`Engine::set_walk_budget`]): one that could not run within it is
3465        // NOT a refusal (see `verify_beef_linear`), and NOT an admission
3466        // unless the caller holds the network's accept of these bytes (the
3467        // E592 lens fold, H1, [`WalkBreachPolicy`]): "not now" before any
3468        // manager is asked, or, under `NetworkAccepted`, this submit goes on
3469        // as under `HistoricalTxNoSpv` and the report says so.
3470        let mut could_not_run = None;
3471        if mode != SubmitMode::HistoricalTxNoSpv {
3472            match walked {
3473                Some(walked) if walked.contains(&txid) => {}
3474                trusted => {
3475                    could_not_run = verify_spv_trusting(
3476                        self.chain_tracker.as_deref(),
3477                        self.verify_scripts,
3478                        &tagged_beef.beef,
3479                        &txid,
3480                        trusted.unwrap_or(&HashSet::new()),
3481                        self.walk_budget,
3482                    )
3483                    .await?;
3484                }
3485            }
3486        }
3487        if let Some(stop) = could_not_run.take() {
3488            match policy {
3489                WalkBreachPolicy::NotNow => {
3490                    warn!(
3491                        "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)",
3492                        stop.subject_txid,
3493                        stop.limb.as_str(),
3494                        stop.at_txid,
3495                        stop.subject_judged,
3496                        stop.what
3497                    );
3498                    return Err(EngineError::WalkCouldNotRun(stop));
3499                }
3500                WalkBreachPolicy::NetworkAccepted => {
3501                    warn!(
3502                        "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)",
3503                        stop.subject_txid,
3504                        stop.limb.as_str(),
3505                        stop.at_txid,
3506                        stop.subject_judged,
3507                        stop.what
3508                    );
3509                    could_not_run = Some(stop);
3510                }
3511            }
3512        }
3513
3514        let mut steak = Steak::new();
3515
3516        // =============================================================
3517        // PHASE 1: VALIDATE (read-only)
3518        // =============================================================
3519        let mut validations = Vec::new();
3520
3521        for topic in &tagged_beef.topics {
3522            validations.push(
3523                self.validate_topic(
3524                    &tx,
3525                    &txid,
3526                    topic,
3527                    tagged_beef.off_chain_values.as_deref(),
3528                    mode,
3529                    context,
3530                    bound,
3531                )
3532                .await,
3533            );
3534        }
3535
3536        // Build preliminary STEAK
3537        for v in &validations {
3538            steak.insert(v.topic.clone(), v.admittance.clone());
3539        }
3540
3541        // =============================================================
3542        // PHASE 2: BROADCAST / SHIP propagation (before mutations)
3543        // =============================================================
3544        if mode == SubmitMode::CurrentTx {
3545            // ── ARC network broadcast ──────────────────────────────────
3546            // Broadcast to miners via ARC, matching the TS Engine pattern.
3547            // Skip if the tx already has a merkle path (already mined).
3548            //
3549            // INCIDENT D1-CALLBACK-FLOOD 2026-09-01: also skip when EVERY
3550            // topic classified the tx as a dupe (already applied) — the
3551            // exact `!v.is_dupe` filter the SHIP arm below has always had,
3552            // ten lines away. Without it, every RE-PRESENT of an
3553            // already-applied tx (cron ad-sync, peer crawl, a client
3554            // retrying a default-mode /submit) re-POSTed to Arcade and
3555            // re-registered the status callback, and registrations
3556            // accumulate per POST on Arcade's side — the flood's fuel. A
3557            // genuinely new tx (any topic non-dupe) broadcasts exactly as
3558            // before.
3559            let all_topics_dupe = !validations.is_empty() && validations.iter().all(|v| v.is_dupe);
3560            if let Some(ref arc) = self.arc_broadcaster {
3561                if all_topics_dupe {
3562                    info!(
3563                        "Skipping ARC broadcast — tx already applied to every topic (re-present)"
3564                    );
3565                } else if tx.merkle_path.is_none() {
3566                    let raw_tx_hex = tx.to_hex();
3567                    match arc.broadcast(&raw_tx_hex).await {
3568                        Ok(arc_txid) => {
3569                            info!("ARC broadcast succeeded: txid={arc_txid}");
3570                        }
3571                        Err(e) => {
3572                            error!("ARC broadcast failed (non-fatal): {e}");
3573                        }
3574                    }
3575                } else {
3576                    info!("Skipping ARC broadcast — tx already has merkle proof");
3577                }
3578            }
3579
3580            // ── SHIP peer propagation ──────────────────────────────────
3581            if let Some(ref broadcaster) = self.broadcaster {
3582                let relevant_topics: Vec<String> = validations
3583                    .iter()
3584                    .filter(|v| {
3585                        !v.is_dupe && !v.failed && !v.admittance.outputs_to_admit.is_empty()
3586                    })
3587                    .map(|v| v.topic.clone())
3588                    .collect();
3589
3590                if !relevant_topics.is_empty() {
3591                    if let Some(ship_ls) = self.lookup_services.get("ls_ship") {
3592                        for topic in &relevant_topics {
3593                            let question = LookupQuestion::new(
3594                                "ls_ship",
3595                                serde_json::json!({ "topics": [topic] }),
3596                            );
3597                            match ship_ls.lookup(&question).await {
3598                                Ok(LookupResult::OutputList(refs)) => {
3599                                    for reference in &refs {
3600                                        if let Ok(Some(output)) = self
3601                                            .storage
3602                                            .find_output(
3603                                                &reference.txid,
3604                                                reference.output_index,
3605                                                None,
3606                                                None,
3607                                                false,
3608                                            )
3609                                            .await
3610                                        {
3611                                            if let Some(domain) =
3612                                                parse_ship_domain_from_script(&output.output_script)
3613                                            {
3614                                                if let Some(ref our_url) = self.config.hosting_url {
3615                                                    if domain.trim_end_matches('/')
3616                                                        == our_url.trim_end_matches('/')
3617                                                    {
3618                                                        continue;
3619                                                    }
3620                                                }
3621                                                if let Err(e) = broadcaster
3622                                                    .broadcast_to_host(&domain, tagged_beef)
3623                                                    .await
3624                                                {
3625                                                    error!(
3626                                                        "SHIP propagation to {domain} failed for topic {topic}: {e}"
3627                                                    );
3628                                                }
3629                                            }
3630                                        }
3631                                    }
3632                                }
3633                                Ok(LookupResult::Answer(_)) => {
3634                                    // SHIP only ever returns OutputList; an Answer here means
3635                                    // a misconfigured replacement service. Skip rather than
3636                                    // attempt to extract refs from a freeform/formula payload.
3637                                    error!(
3638                                        "SHIP lookup for topic {topic} returned a pre-formed \
3639                                         LookupAnswer; expected OutputList. Skipping fanout."
3640                                    );
3641                                }
3642                                Err(e) => {
3643                                    error!("SHIP lookup for topic {topic} failed: {e}");
3644                                }
3645                            }
3646                        }
3647                    }
3648                }
3649            }
3650        }
3651
3652        Ok((validations, steak, tx, txid, could_not_run))
3653    }
3654
3655    // ========================================================================
3656    // Lookup
3657    // ========================================================================
3658
3659    /// Answer a lookup query.
3660    ///
3661    /// Delegates to the appropriate LookupService, then hydrates results with BEEF.
3662    ///
3663    /// When `history_selector` is provided, each output is hydrated with its
3664    /// ancestor spend chain (via `get_utxo_history`) before building the response.
3665    /// This matches the TS Engine behavior when a history selector is configured.
3666    pub async fn lookup(
3667        &self,
3668        question: &LookupQuestion,
3669        history_selector: Option<HistorySelector>,
3670    ) -> Result<LookupAnswer, EngineError> {
3671        Ok(self.lookup_with_txids(question, history_selector).await?.0)
3672    }
3673
3674    /// Same as [`Engine::lookup`], additionally returning each hydrated
3675    /// output's txid, aligned index-for-index with the `OutputList` items
3676    /// (empty for pre-formed `Answer`s).
3677    ///
3678    /// The txid IS the storage primary key the row was just fetched by —
3679    /// handing it to the caller lets the aggregated wire serializer write it
3680    /// directly instead of re-deriving it with a full BEEF parse plus a
3681    /// double-SHA256 per output (bsv-low #289).
3682    pub async fn lookup_with_txids(
3683        &self,
3684        question: &LookupQuestion,
3685        history_selector: Option<HistorySelector>,
3686    ) -> Result<(LookupAnswer, Vec<String>), EngineError> {
3687        let service = self
3688            .lookup_services
3689            .get(&question.service)
3690            .ok_or_else(|| EngineError::LookupServiceNotFound(question.service.clone()))?;
3691
3692        // Preserve WHOSE fault it was. A lookup service reports a malformed
3693        // query as `InvalidQuery` / `Unsupported`; stringifying both into
3694        // `LookupFailed` (as this did) threw that away and reported every
3695        // caller mistake as a 500.
3696        let result = service.lookup(question).await.map_err(|e| match e {
3697            LookupServiceError::InvalidQuery(m) | LookupServiceError::Unsupported(m) => {
3698                EngineError::InvalidQuery(m)
3699            }
3700            other => EngineError::LookupFailed(other.to_string()),
3701        })?;
3702
3703        // Two paths per LookupResult:
3704        // - OutputList(refs): the LS yields outpoints; we hydrate each with
3705        //   BEEF (and optional ancestor chain via history_selector) and
3706        //   assemble LookupAnswer::OutputList.
3707        // - Answer(answer): the LS already produced the full answer
3708        //   (Freeform/Formula); pass through verbatim. The Engine does NOT
3709        //   apply history-selector hydration to a pre-formed Answer — the
3710        //   LS is presumed to have embedded whatever ancestry it wants.
3711        let refs = match result {
3712            LookupResult::OutputList(refs) => refs,
3713            LookupResult::Answer(answer) => return Ok((answer, Vec::new())),
3714        };
3715
3716        // ONE batched hydration query for the whole result set instead of a
3717        // D1 round-trip per row (the #289 N+1). Order is preserved: the
3718        // batch contract returns outputs in input-outpoint order, skipping
3719        // outpoints with no stored row. A storage error is a REAL error now
3720        // (it used to be swallowed per-row into a silently-empty list — the
3721        // "vanishing table" failure mode): an uncertain read must never
3722        // masquerade as a confidently-empty lobby.
3723        let outpoints: Vec<Outpoint> = refs
3724            .iter()
3725            .map(|r| Outpoint::new(&r.txid, r.output_index))
3726            .collect();
3727        let found = self
3728            .storage
3729            .find_outputs_by_outpoints(&outpoints, true)
3730            .await
3731            .map_err(|e| EngineError::StorageError(e.to_string()))?;
3732
3733        let mut outputs = Vec::new();
3734        let mut txids = Vec::new();
3735        for output in found {
3736            // If history selector provided, hydrate ancestor chain
3737            let final_output = if history_selector.is_some() {
3738                match self
3739                    .get_utxo_history(&output, history_selector.clone())
3740                    .await
3741                {
3742                    Ok(Some(hydrated)) => hydrated,
3743                    _ => output,
3744                }
3745            } else {
3746                output
3747            };
3748
3749            // Skip an output whose BEEF is missing OR empty: a lookup item
3750            // without decodable BEEF is useless to the client (it can only
3751            // drop it), and returning an empty-BEEF row is what made a fresh
3752            // LOW table show up in a query yet be undecodable/invisible to
3753            // opponents (the "vanishing table" — 2026-07-11). Only ever
3754            // return fully-hydrated, decodable outputs.
3755            if let Some(beef) = final_output.beef {
3756                if !beef.is_empty() {
3757                    txids.push(final_output.txid.clone());
3758                    outputs.push(OutputListItem {
3759                        beef,
3760                        output_index: final_output.output_index,
3761                        context: None,
3762                    });
3763                }
3764            }
3765        }
3766
3767        Ok((LookupAnswer::OutputList { outputs }, txids))
3768    }
3769
3770    // ========================================================================
3771    // Metadata
3772    // ========================================================================
3773
3774    // ========================================================================
3775    // UTXO History
3776    // ========================================================================
3777
3778    /// Traverse and return the history of a UTXO.
3779    ///
3780    /// If no `history_selector` is provided, returns the output as-is.
3781    /// If a depth selector is provided, includes up to N levels of ancestor
3782    /// transactions by embedding them as source_transactions in the BEEF.
3783    pub async fn get_utxo_history(
3784        &self,
3785        output: &Output,
3786        history_selector: Option<HistorySelector>,
3787    ) -> Result<Option<Output>, EngineError> {
3788        let Some(selector) = history_selector else {
3789            return Ok(Some(output.clone()));
3790        };
3791
3792        // Verify BEEF exists before attempting hydration
3793        if output.beef.is_none() {
3794            return Err(EngineError::Other(
3795                "Output must have associated transaction BEEF!".into(),
3796            ));
3797        }
3798
3799        match self.hydrate_utxo_history(output, &selector, 0).await {
3800            Ok(Some(hydrated_output)) => Ok(Some(hydrated_output)),
3801            Ok(None) => Ok(None),
3802            Err(e) => Err(EngineError::Other(format!(
3803                "Error retrieving UTXO history: {e}"
3804            ))),
3805        }
3806    }
3807
3808    /// Recursive UTXO history hydration.
3809    fn hydrate_utxo_history<'a>(
3810        &'a self,
3811        output: &'a Output,
3812        selector: &'a HistorySelector,
3813        current_depth: u32,
3814    ) -> std::pin::Pin<
3815        Box<dyn std::future::Future<Output = Result<Option<Output>, EngineError>> + 'a>,
3816    > {
3817        Box::pin(async move {
3818            let beef_data = output.beef.as_ref().ok_or_else(|| {
3819                EngineError::Other("Output must have associated transaction BEEF!".into())
3820            })?;
3821
3822            // Check if we should traverse at this depth
3823            let should_traverse = match selector {
3824                HistorySelector::Depth(max_depth) => current_depth <= *max_depth,
3825            };
3826
3827            if !should_traverse {
3828                return Ok(None);
3829            }
3830
3831            // Start from the STORED BEEF, not from a re-parsed subject.
3832            //
3833            // #4: this used to be `Transaction::from_beef(beef_data, None)` followed
3834            // by `tx.to_beef(true)`, which keeps only the subject transaction and
3835            // therefore DESTROYS the stored BUMPs. Measured on prod before the fix:
3836            // `ls_low` byGameId `73de6e48…` returned 1,172 B / 1 tx / 1 bump (PROVEN)
3837            // with no header, and 676 B / 1 tx / 0 bumps with `x-history-depth: 3` —
3838            // a caller asking for MORE provenance got back a BEEF no wallet can
3839            // verify, silently, behind a 200.
3840            //
3841            // Accumulating into the stored BEEF fixes both halves: its own bumps
3842            // survive, and so does any ancestry that arrived inside the submitted
3843            // bytes but was never admitted as an output of its own (a foreign
3844            // parent), which the `outputs_consumed` walk below cannot see.
3845            let mut acc = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3846                .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3847
3848            // For each consumed output, recursively hydrate and embed as source transaction
3849            for consumed in &output.outputs_consumed {
3850                if let Ok(Some(child_output)) = self
3851                    .storage
3852                    .find_output(&consumed.txid, consumed.output_index, None, None, true)
3853                    .await
3854                {
3855                    if let Ok(Some(hydrated_child)) = self
3856                        .hydrate_utxo_history(&child_output, selector, current_depth + 1)
3857                        .await
3858                    {
3859                        // MERGE the hydrated ancestor in, rather than embedding it
3860                        // as a `source_transaction` on a re-parsed subject: a merge
3861                        // carries the child's own BUMPs across, which is the whole
3862                        // point of asking for history. An unparseable child is
3863                        // skipped — never allowed to poison the accumulator.
3864                        if let Some(ref child_beef) = hydrated_child.beef {
3865                            if let Ok(child) = Beef::from_binary(child_beef) {
3866                                acc.merge_beef(&child);
3867                            }
3868                        }
3869                    }
3870                }
3871            }
3872
3873            Ok(Some(Output {
3874                beef: Some(acc.to_binary()),
3875                ..output.clone()
3876            }))
3877        })
3878    }
3879
3880    // ========================================================================
3881    // Metadata
3882    // ========================================================================
3883
3884    pub async fn list_topic_managers(&self) -> HashMap<String, ServiceMetadata> {
3885        let mut result = HashMap::new();
3886        for (name, manager) in &self.managers {
3887            let meta = manager.get_metadata().await;
3888            result.insert(name.clone(), meta);
3889        }
3890        result
3891    }
3892
3893    /// List all registered lookup services with their metadata.
3894    pub async fn list_lookup_service_providers(&self) -> HashMap<String, ServiceMetadata> {
3895        let mut result = HashMap::new();
3896        for (name, service) in &self.lookup_services {
3897            let meta = service.get_metadata().await;
3898            result.insert(name.clone(), meta);
3899        }
3900        result
3901    }
3902
3903    /// Get documentation for a specific topic manager.
3904    pub async fn get_documentation_for_topic_manager(&self, name: &str) -> String {
3905        match self.managers.get(name) {
3906            Some(manager) => manager.get_documentation().await,
3907            None => "No documentation found!".to_string(),
3908        }
3909    }
3910
3911    /// Get documentation for a specific lookup service.
3912    pub async fn get_documentation_for_lookup_service(&self, name: &str) -> String {
3913        match self.lookup_services.get(name) {
3914            Some(service) => service.get_documentation().await,
3915            None => "No documentation found!".to_string(),
3916        }
3917    }
3918
3919    // ========================================================================
3920    // GASP endpoints
3921    // ========================================================================
3922
3923    /// Respond to a GASP initial sync request.
3924    ///
3925    /// Returns UTXOs for the given topic since the requested score.
3926    ///
3927    /// The caller-supplied `limit` is CLAMPED to
3928    /// [`Engine::SYNC_RESPONSE_MAX_LIMIT`] (and defaulted when absent):
3929    /// `/requestSyncResponse` is a PUBLIC route, and an absent limit used to
3930    /// mean an unbounded scan of the outputs table (bsv-low #291). Bounding
3931    /// is lossless for sync correctness because the initiator
3932    /// ([`crate::gasp::GASPSync`]) pages for as long as its `since` score
3933    /// cursor advances — a truncated page is fetched by its NEXT request
3934    /// within the same run (gate finding M1: pagination must not demand a
3935    /// full page, which this clamp can make impossible).
3936    pub async fn provide_foreign_sync_response(
3937        &self,
3938        request: &GASPInitialRequest,
3939        topic: &str,
3940    ) -> Result<GASPInitialResponse, EngineError> {
3941        let limit = Self::clamp_sync_limit(request.limit);
3942        let outputs = self
3943            .storage
3944            .find_utxos_for_topic(topic, Some(request.since as f64), Some(limit), false)
3945            .await
3946            .map_err(|e| EngineError::StorageError(e.to_string()))?;
3947
3948        Ok(GASPInitialResponse {
3949            utxo_list: outputs
3950                .iter()
3951                .map(|o| GASPOutput {
3952                    txid: o.txid.clone(),
3953                    output_index: o.output_index,
3954                    score: o.score.unwrap_or(0.0),
3955                })
3956                .collect(),
3957            since: request.since,
3958        })
3959    }
3960
3961    /// Provide a GASPNode for a specific transaction within a graph.
3962    ///
3963    /// Searches the BEEF tree of the root output (identified by graphID) for
3964    /// the requested txid. If not found in the BEEF tree, recurses through
3965    /// outputsConsumed in storage.
3966    pub async fn provide_foreign_gasp_node(
3967        &self,
3968        graph_id: &str,
3969        txid: &str,
3970        output_index: u32,
3971    ) -> Result<GASPNode, EngineError> {
3972        let root_outpoint = Outpoint::from_graph_id(graph_id)
3973            .ok_or_else(|| EngineError::Other(format!("Invalid graphID: {graph_id}")))?;
3974
3975        let root_output = self
3976            .storage
3977            .find_output(
3978                &root_outpoint.txid,
3979                root_outpoint.output_index,
3980                None,
3981                None,
3982                true,
3983            )
3984            .await
3985            .map_err(|e| EngineError::StorageError(e.to_string()))?
3986            .ok_or(EngineError::NodeNotFound)?;
3987
3988        self.hydrate_gasp_node(&root_output, graph_id, txid, output_index)
3989            .await
3990    }
3991
3992    /// Recursively search a BEEF tree for a specific txid and return a GASPNode.
3993    fn hydrate_gasp_node<'a>(
3994        &'a self,
3995        output: &'a Output,
3996        graph_id: &'a str,
3997        txid: &'a str,
3998        output_index: u32,
3999    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GASPNode, EngineError>> + 'a>>
4000    {
4001        Box::pin(async move {
4002            let beef_data = output.beef.as_ref().ok_or(EngineError::NodeNotFound)?;
4003
4004            // The stored body's SUBJECT by the same order-independent rule
4005            // (`subject.rs`) — a plain stored body's last tx can be an ancestor.
4006            let root = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
4007                .ok()
4008                .and_then(|mut b| crate::subject::subject_txid_of(&mut b));
4009            let root_tx = beef_limits::transaction_from_beef(
4010                beef_data,
4011                root.as_deref(),
4012                &beef_limits::STORED_BEEF_LIMITS,
4013            )
4014            .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
4015
4016            // Search the transaction tree for the requested txid
4017            if let Some(node) = Self::search_tx_tree(&root_tx, graph_id, txid, output_index) {
4018                return Ok(node);
4019            }
4020
4021            // Fallback: try parsing BEEF with the target txid directly
4022            // (some BEEF structures have the tx in the list but not linked as source_transaction)
4023            if let Some(node) = Self::search_beef_for_txid(beef_data, graph_id, txid, output_index)
4024            {
4025                return Ok(node);
4026            }
4027
4028            // Not found in BEEF tree — recurse through outputsConsumed in storage
4029            for consumed in &output.outputs_consumed {
4030                if let Ok(Some(consumed_output)) = self
4031                    .storage
4032                    .find_output(&consumed.txid, consumed.output_index, None, None, true)
4033                    .await
4034                {
4035                    if let Ok(node) = self
4036                        .hydrate_gasp_node(&consumed_output, graph_id, txid, output_index)
4037                        .await
4038                    {
4039                        return Ok(node);
4040                    }
4041                }
4042            }
4043
4044            // The graph root is known but neither its BEEF nor anything it
4045            // consumed holds the requested transaction. That is a definite
4046            // "not held", the same answer as an unknown root: the reference
4047            // throws `Unable to find output associated with your request!`
4048            // here and its route answers 400. A syncing peer reads the 400 as
4049            // `GASPError::NodeNotFound` and prunes a decoy input on it (the D8
4050            // rule, `GASPSync::process_incoming_node`); a 500 would read as a
4051            // fault of the moment and strand the chain behind the decoy.
4052            Err(EngineError::NodeNotFound)
4053        })
4054    }
4055
4056    /// Search a Transaction tree (via sourceTransaction links) for a specific txid.
4057    fn search_tx_tree(
4058        tx: &Transaction,
4059        graph_id: &str,
4060        target_txid: &str,
4061        output_index: u32,
4062    ) -> Option<GASPNode> {
4063        let current_txid = tx.id();
4064
4065        if current_txid == target_txid {
4066            let mut node = GASPNode {
4067                graph_id: graph_id.to_string(),
4068                raw_tx: tx.to_hex(),
4069                output_index,
4070                proof: None,
4071                tx_metadata: None,
4072                output_metadata: None,
4073                inputs: None,
4074            };
4075            if let Some(ref merkle_path) = tx.merkle_path {
4076                node.proof = Some(merkle_path.to_hex());
4077            }
4078            return Some(node);
4079        }
4080
4081        // Recurse into inputs via source_transaction
4082        for input in &tx.inputs {
4083            if let Some(ref source_tx) = input.source_transaction {
4084                if let Some(node) =
4085                    Self::search_tx_tree(source_tx, graph_id, target_txid, output_index)
4086                {
4087                    return Some(node);
4088                }
4089            }
4090        }
4091
4092        None
4093    }
4094
4095    /// Alternative search: try parsing the BEEF with the target txid directly.
4096    /// Some BEEF structures may not link source_transaction on inputs but
4097    /// still contain the transaction in the BEEF's transaction list.
4098    fn search_beef_for_txid(
4099        beef_data: &[u8],
4100        graph_id: &str,
4101        target_txid: &str,
4102        output_index: u32,
4103    ) -> Option<GASPNode> {
4104        // Try parsing BEEF with the specific txid
4105        if let Ok(tx) = beef_limits::transaction_from_beef(
4106            beef_data,
4107            Some(target_txid),
4108            &beef_limits::STORED_BEEF_LIMITS,
4109        ) {
4110            if tx.id() == target_txid {
4111                let mut node = GASPNode {
4112                    graph_id: graph_id.to_string(),
4113                    raw_tx: tx.to_hex(),
4114                    output_index,
4115                    proof: None,
4116                    tx_metadata: None,
4117                    output_metadata: None,
4118                    inputs: None,
4119                };
4120                if let Some(ref merkle_path) = tx.merkle_path {
4121                    node.proof = Some(merkle_path.to_hex());
4122                }
4123                return Some(node);
4124            }
4125        }
4126        None
4127    }
4128
4129    // ========================================================================
4130    // Merkle proof handling
4131    // ========================================================================
4132
4133    /// Handle a new merkle proof for a transaction.
4134    ///
4135    /// When a transaction gets mined, ARC calls back with the merkle proof.
4136    /// This method:
4137    /// 1. Finds all outputs for the txid in storage
4138    /// 2. Parses the BEEF, updates the merkle path in the transaction tree
4139    /// 3. Serializes updated BEEF back to storage
4140    /// 4. Recursively updates the consumedBy chain (outputs that spent this one)
4141    /// 5. Updates blockHeight on the outputs
4142    pub async fn handle_new_merkle_proof(
4143        &self,
4144        txid: &str,
4145        proof_hex: &str,
4146        block_height: Option<u32>,
4147    ) -> Result<(), EngineError> {
4148        let outputs = self
4149            .storage
4150            .find_outputs_for_transaction(txid, true)
4151            .await
4152            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4153
4154        if outputs.is_empty() {
4155            return Err(EngineError::Other(
4156                "Could not find matching transaction outputs for proof ingest!".into(),
4157            ));
4158        }
4159
4160        // Parse merkle proof if provided
4161        let proof = if proof_hex.is_empty() {
4162            None
4163        } else {
4164            Some(
4165                beef_limits::merkle_path_from_hex(proof_hex, beef_limits::PUSH_PROOF_MAX_BYTES)
4166                    .map_err(|e| EngineError::Other(format!("Invalid merkle proof hex: {e}")))?,
4167            )
4168        };
4169
4170        for output in &outputs {
4171            // Stitch the proof into the STORED BEEF (#284) — never rebuild it
4172            // around a re-picked subject; see stitch_proof_into_stored_beef.
4173            if let (Some(ref beef_data), Some(ref proof)) = (&output.beef, &proof) {
4174                if let Some(new_beef) = Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4175                {
4176                    let _ = self
4177                        .storage
4178                        .update_transaction_beef(&output.txid, &new_beef)
4179                        .await;
4180                }
4181            }
4182
4183            // Update block height
4184            if let Some(height) = block_height {
4185                let _ = self
4186                    .storage
4187                    .update_output_block_height(
4188                        &output.txid,
4189                        output.output_index,
4190                        &output.topic,
4191                        height,
4192                    )
4193                    .await;
4194            }
4195
4196            // Recursively update consumedBy chain
4197            for consuming in &output.consumed_by {
4198                if let Ok(consumed_outputs) = self
4199                    .storage
4200                    .find_outputs_for_transaction(&consuming.txid, true)
4201                    .await
4202                {
4203                    for consumed_output in &consumed_outputs {
4204                        // The consuming row's BEEF carries this tx as an
4205                        // ancestor — stitch the same bump in there too, again
4206                        // without rebuilding the tx set (#284: this branch is
4207                        // the one that was observed replacing a head-spend's
4208                        // stored BEEF with parent ancestry only).
4209                        if let (Some(ref beef_data), Some(ref proof)) =
4210                            (&consumed_output.beef, &proof)
4211                        {
4212                            if let Some(new_beef) =
4213                                Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4214                            {
4215                                let _ = self
4216                                    .storage
4217                                    .update_transaction_beef(&consumed_output.txid, &new_beef)
4218                                    .await;
4219                            }
4220                        }
4221                    }
4222                }
4223            }
4224        }
4225
4226        Ok(())
4227    }
4228
4229    /// Complete missing merkle proofs for stored, confirmed transactions.
4230    ///
4231    /// The `/submit`-admitted ingest path stores a proofless BEEF for each
4232    /// transaction (the submitter does not yet have a merkle proof at submit
4233    /// time). When such a transaction later mines, nothing in the overlay
4234    /// re-fetches its proof, so its stored BEEF stays proofless forever — and a
4235    /// frontend that trims `inputBEEF` to `{source tx + its BUMP}` for a P2PKH
4236    /// payment-import spend has no BUMP to trim to, hanging the import (#130).
4237    ///
4238    /// This pass closes that gap from chain (the one blessed server-side WoC
4239    /// use). It:
4240    /// 1. Pulls a bounded page (`limit`) of stored `(txid, beef)` from storage.
4241    /// 2. Parses each BEEF and keeps only those whose target tx still lacks a
4242    ///    merkle proof (`Beef::find_txid(txid).has_proof() == false`).
4243    /// 3. For each proofless candidate, fetches its BUMP via the configured
4244    ///    [`AncestorFetcher`](crate::gasp::AncestorFetcher). If a proof comes
4245    ///    back, it calls [`Self::handle_new_merkle_proof`], which stitches the
4246    ///    BUMP into the stored BEEF (D1 + R2 mirror), updates output block
4247    ///    height, and recurses the `consumedBy` chain.
4248    /// 4. Skips (does NOT error) txs the fetcher can't prove yet (still
4249    ///    unconfirmed) — those are simply retried on a later tick.
4250    ///
4251    /// **No-op when no [`AncestorFetcher`] is configured** — production-safe
4252    /// default (the same opt-in switch as GASP ancestor hydration). Bounded by
4253    /// `limit` (a per-tick budget, like the platform fetcher's own budget).
4254    ///
4255    /// `min_age_secs` is the PUSH-PRIMARY BACKSTOP gate (bsv-low #228 /
4256    /// arcade#259): since the Arcade MINED webhook (`/arc-ingest`) pushes a
4257    /// verified proof ~150 ms after a tx mines, this poll pass is a BACKSTOP,
4258    /// not the primary source — rows stored less than `min_age_secs` ago are
4259    /// skipped entirely (their push is still expected). Pass `0` to disable
4260    /// the gate (poll everything — the pre-#228 behaviour, and the degradation
4261    /// mode if pushes stop: an old-enough row is ALWAYS polled, so webhook
4262    /// loss degrades to polling, never to nothing). Unknown-age rows are
4263    /// always eligible (fail-safe — see the storage-trait doc).
4264    pub async fn complete_missing_proofs(
4265        &self,
4266        limit: u64,
4267        min_age_secs: u64,
4268    ) -> Result<ProofCompletionSummary, EngineError> {
4269        let mut summary = ProofCompletionSummary::default();
4270
4271        // Production-safe: with no fetcher there is no chain source to complete
4272        // from, so this is a pure no-op (matches GASP-hydration opt-in).
4273        let Some(fetcher) = self.ancestor_fetcher.clone() else {
4274            return Ok(summary);
4275        };
4276
4277        let candidates = self
4278            .storage
4279            .find_transactions_for_proof_check(limit, min_age_secs)
4280            .await
4281            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4282        summary.scanned = candidates.len();
4283
4284        for cand in candidates {
4285            // Read the stored BEEF for the subject's OWN bump by streaming
4286            // folds (one element in hand, no `Beef` built: NL-6); skip
4287            // anything unparseable (don't error the whole pass on one bad row).
4288            let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4289                Ok(b) => b,
4290                Err(e) => {
4291                    warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4292                    continue;
4293                }
4294            };
4295
4296            // Already structurally proven? The stored BEEF carries a merkle bump
4297            // for the target tx, but its backend `has_proof` flag may still be 0
4298            // — EITHER a legitimate GASP-synced proof written before overlay
4299            // migration 0010 (which defaulted every existing row to 0), OR an
4300            // admit-time bump that was NEVER SPV-verified (`/submit` skips SPV on
4301            // historical topics) or is outright forged. Serve-time BEEF trimming
4302            // trusts the `has_proof` flag, so latching one on STRUCTURE ALONE
4303            // would let a forged bump be trimmed on (#192/#193 MEDIUM). Re-verify
4304            // the STORED bump against chaintracks (via the fetcher — its header
4305            // source is the only arbiter of a merkle root). Genuine → latch
4306            // (fast, no re-fetch). Otherwise DON'T trust it: fall through to the
4307            // fetch+stitch path below, which overwrites the bump with a
4308            // chaintracks-verified one (or leaves the row proofless for retry).
4309            // Fail-closed throughout.
4310            // the tx's OWN bump (its `bump_index`), never `find_bump`
4311            // (the FIRST bump containing the txid — the stale one after a
4312            // same-height reorg; review HIGH-1).
4313            if let Some(bump_hex) = own_bump
4314                .as_ref()
4315                .map(bsv_rs::transaction::MerklePath::to_hex)
4316            {
4317                if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4318                    // Idempotent + best-effort: a failure here is logged, not
4319                    // fatal — the row simply lingers one more tick.
4320                    // bsv-low M19 R2 round 3 (review MED-2): latch WITH the
4321                    // anchor height (from the tx's OWN verified bump) so the
4322                    // revalidation sweep's transactions leg can window this
4323                    // row — `mark_transaction_proven` alone left every
4324                    // fast-path row anchorless, in no window forever.
4325                    let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4326                        .ok()
4327                        .map(|mp| u64::from(mp.block_height));
4328                    if let Err(e) = self
4329                        .storage
4330                        .mark_transaction_proven_at(&cand.txid, height)
4331                        .await
4332                    {
4333                        warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4334                    } else {
4335                        summary.already_proven += 1;
4336                    }
4337                    continue;
4338                }
4339                warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4340            }
4341            // Unverifiable structural bump → treat as proofless (fall through).
4342            summary.proofless += 1;
4343
4344            // Fetch this tx's own VERIFIED bump from chain — PROOF ONLY (the raw
4345            // is already in the stored BEEF, so no redundant raw fetch; #192/#193
4346            // FIX 2). A tx with no verified proof yet (still unconfirmed, or an
4347            // unverifiable/forged courier response) is skipped, not errored.
4348            let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4349                summary.still_unconfirmed += 1;
4350                continue;
4351            };
4352
4353            // Pull the block height out of the BUMP so the output rows update too.
4354            let block_height =
4355                beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4356                    .ok()
4357                    .map(|mp| mp.block_height);
4358
4359            match self
4360                .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4361                .await
4362            {
4363                Ok(()) => summary.completed += 1,
4364                Err(e) => {
4365                    warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4366                    summary.stitch_failed += 1;
4367                }
4368            }
4369        }
4370
4371        info!(
4372            scanned = summary.scanned,
4373            proofless = summary.proofless,
4374            completed = summary.completed,
4375            still_unconfirmed = summary.still_unconfirmed,
4376            fetch_failed = summary.fetch_failed,
4377            stitch_failed = summary.stitch_failed,
4378            already_proven = summary.already_proven,
4379            "[PROOF COMPLETION] pass complete"
4380        );
4381
4382        Ok(summary)
4383    }
4384
4385    /// Recursively update merkle paths in a transaction's input tree.
4386    ///
4387    /// If the transaction's id matches txid, set its merkle_path.
4388    /// Otherwise, recurse into sourceTransactions of each input.
4389    /// Stitch a verified BUMP for `txid` into a STORED BEEF, preserving every
4390    /// transaction and every other proof in it (#284).
4391    ///
4392    /// The old path round-tripped the stored bytes through
4393    /// `Transaction::from_beef(bytes, None)` -> `to_beef(true)`, and
4394    /// `from_beef(_, None)` picks **`txs.last()`** — it ignores the atomic
4395    /// subject pointer entirely. Whenever the subject was not the last tx in
4396    /// wire order, a PARENT was picked, `to_beef(true)` then serialized that
4397    /// parent's world, and the rewrite dropped the subject transaction from its
4398    /// own stored BEEF — observed live on every pf head-spend (list / reprice /
4399    /// delist / buy) the moment it mined, breaking the NEXT spend on that name
4400    /// (zanaadu#284). `update_input_proofs`' "already has a proof — update it"
4401    /// arm could even stamp the child's bump onto that mis-picked parent.
4402    /// This is the same destroy-the-stored-BEEF class the #4 fix note above
4403    /// (`hydrate_utxo_history`) already documents for lookup hydration.
4404    ///
4405    /// Operating on [`Beef`] directly has none of those failure modes:
4406    /// `merge_bump` dedupes against existing bumps by (height, root) and
4407    /// assigns the bump ONLY to transactions that appear as flagged txid
4408    /// leaves, so stitching tx X's proof can never mislabel tx Y, and the tx
4409    /// set is never rebuilt at all.
4410    ///
4411    /// Returns `None` when the bytes do not parse or `txid` is not in this
4412    /// BEEF (nothing to stitch — e.g. a consuming row whose ancestry got
4413    /// trimmed), so callers skip the write instead of writing garbage.
4414    ///
4415    /// bsv-low M19 R2 round 3 (review HIGH-1): a same-height DIFFERENT-root
4416    /// proof (the 2026-09-07 reorg shape) does NOT combine in `merge_bump`
4417    /// (the roots differ) — it is pushed as a SECOND bump, and
4418    /// `update_bump_indices` assigns it only to txs whose `bump_index` is
4419    /// `None`, so the reorged subject keeps its index on the STALE bump.
4420    /// Left alone, `has_proof`/`proofHeight` and every `find_txid(txid)
4421    /// .bump_index()` reader then keep pointing at the orphan bump, and the
4422    /// row refutes again next sweep pass forever. So after the merge, FORCE
4423    /// the subject onto the bump that actually proves it, then drop any bump
4424    /// no transaction references (the orphan's, now unreferenced).
4425    pub fn stitch_proof_into_stored_beef(
4426        stored: &[u8],
4427        txid: &str,
4428        proof: &bsv_rs::transaction::MerklePath,
4429    ) -> Option<Vec<u8>> {
4430        let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4431        beef.find_txid(txid)?;
4432        let i = beef.merge_bump(proof.clone());
4433        // Does the merged bump actually prove `txid` (a flagged txid leaf)?
4434        // (It always does for a completion proof; the guard keeps a stray
4435        // caller from mis-anchoring a subject onto an ancestor's bump.)
4436        let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4437            b.path.first().is_some_and(|leaves| {
4438                leaves
4439                    .iter()
4440                    .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4441            })
4442        });
4443        if proves_subject {
4444            if let Some(tx) = beef.find_txid_mut(txid) {
4445                tx.set_bump_index(Some(i));
4446            }
4447        }
4448        Self::gc_unreferenced_bumps(&mut beef);
4449        Some(beef.to_binary())
4450    }
4451
4452    /// Drop every BUMP no transaction references and re-index the survivors
4453    /// (bsv-low M19 R2 round 3, review HIGH-1): after the subject is moved
4454    /// off an orphan bump, that bump is usually dead weight the next reader
4455    /// would still `find_bump`. A bump still referenced by ANOTHER tx (a
4456    /// sibling mined in the same block) is kept.
4457    fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4458        let referenced: std::collections::HashSet<usize> = beef
4459            .txs
4460            .iter()
4461            .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4462            .collect();
4463        if referenced.len() == beef.bumps.len() {
4464            return;
4465        }
4466        let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4467        let mut kept = Vec::with_capacity(referenced.len());
4468        for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4469            if referenced.contains(&old) {
4470                remap[old] = Some(kept.len());
4471                kept.push(bump);
4472            }
4473        }
4474        beef.bumps = kept;
4475        for tx in &mut beef.txs {
4476            if let Some(old) = tx.bump_index() {
4477                tx.set_bump_index(remap[old]);
4478            }
4479        }
4480    }
4481
4482    // ========================================================================
4483    // Advertisement sync
4484    // ========================================================================
4485
4486    /// Sync SHIP/SLAP advertisements with configured managers and services.
4487    ///
4488    /// Creates missing advertisements and revokes stale ones. Returns a
4489    /// [`SyncAdvertisementsReport`] so a create/submit failure is VISIBLE to
4490    /// the caller (bsv-low #320 defect 3a): this method used to swallow both
4491    /// into `error!` and return `Ok(())`, so `/admin/syncAdvertisements`
4492    /// reported `success` while zero advertisements were admitted locally —
4493    /// a caller could not tell a converged no-op from a silent failure, and
4494    /// every cycle re-created the full ad set.
4495    pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4496        let mut report = SyncAdvertisementsReport::default();
4497
4498        let Some(advertiser) = &self.advertiser else {
4499            return Ok(report); // No advertiser configured
4500        };
4501
4502        let hosting_url = match &self.config.hosting_url {
4503            Some(url) if !url.is_empty() => url.clone(),
4504            _ => return Ok(report), // No hosting URL
4505        };
4506
4507        // Get configured topics and services
4508        let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4509        let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4510
4511        if self.config.suppress_default_sync_advertisements {
4512            configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4513            configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4514        }
4515
4516        // Fetch current advertisements. A read failure REFUSES creation
4517        // (#320 M2): swallowing it into "current ads = none" made a
4518        // transient D1/storage fault re-create — and re-pay for — the
4519        // entire advertisement set, reported as a legitimate `to_create`.
4520        let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4521            Ok(ads) => ads,
4522            Err(e) => {
4523                error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4524                report.lookup_error = Some(format!("ship: {e}"));
4525                return Ok(report);
4526            }
4527        };
4528        let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4529            Ok(ads) => ads,
4530            Err(e) => {
4531                error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4532                report.lookup_error = Some(format!("slap: {e}"));
4533                return Ok(report);
4534            }
4535        };
4536
4537        // Determine what to create
4538        let ships_to_create: Vec<AdvertisementData> = configured_topics
4539            .iter()
4540            .filter(|topic| {
4541                !current_ship
4542                    .iter()
4543                    .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4544            })
4545            .map(|topic| AdvertisementData {
4546                protocol: Protocol::Ship,
4547                topic_or_service_name: topic.clone(),
4548            })
4549            .collect();
4550
4551        let slaps_to_create: Vec<AdvertisementData> = configured_services
4552            .iter()
4553            .filter(|service| {
4554                !current_slap
4555                    .iter()
4556                    .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4557            })
4558            .map(|service| AdvertisementData {
4559                protocol: Protocol::Slap,
4560                topic_or_service_name: service.clone(),
4561            })
4562            .collect();
4563
4564        // Determine what to revoke
4565        let ships_to_revoke: Vec<Advertisement> = current_ship
4566            .into_iter()
4567            .filter(|a| !configured_topics.contains(&a.topic_or_service))
4568            .collect();
4569
4570        let slaps_to_revoke: Vec<Advertisement> = current_slap
4571            .into_iter()
4572            .filter(|a| !configured_services.contains(&a.topic_or_service))
4573            .collect();
4574
4575        // Create new advertisements
4576        let mut all_to_create = ships_to_create;
4577        all_to_create.extend(slaps_to_create);
4578        report.to_create = all_to_create.len();
4579        if !all_to_create.is_empty() {
4580            match advertiser.create_advertisements(&all_to_create).await {
4581                Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4582                    Ok(steak) => {
4583                        for (topic, instructions) in &steak {
4584                            report
4585                                .admitted
4586                                .insert(topic.clone(), instructions.outputs_to_admit.len());
4587                        }
4588                    }
4589                    Err(e) => {
4590                        error!("Failed to submit new advertisements: {e}");
4591                        report.submit_error = Some(e.to_string());
4592                    }
4593                },
4594                Err(e) => {
4595                    error!("Failed to create advertisements: {e}");
4596                    report.create_error = Some(e.to_string());
4597                }
4598            }
4599        }
4600
4601        // Revoke stale advertisements
4602        let mut all_to_revoke = ships_to_revoke;
4603        all_to_revoke.extend(slaps_to_revoke);
4604        report.to_revoke = all_to_revoke.len();
4605        if !all_to_revoke.is_empty() {
4606            match advertiser.revoke_advertisements(&all_to_revoke).await {
4607                // An advertiser that declines to build a revocation (e.g. the
4608                // CF advertiser's documented v1 no-op) returns an EMPTY
4609                // TaggedBEEF — submitting it would only manufacture a parse
4610                // error. Skip, but say so in the report (#320 L2): the stale
4611                // ads stay on-chain until they age out.
4612                Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4613                    report.revoke_skipped = all_to_revoke.len();
4614                }
4615                Ok(tagged_beef) => {
4616                    if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4617                        error!("Failed to submit revocation: {e}");
4618                        report.revoke_submit_error = Some(e.to_string());
4619                    }
4620                }
4621                Err(e) => {
4622                    error!("Failed to revoke advertisements: {e}");
4623                    report.revoke_error = Some(e.to_string());
4624                }
4625            }
4626        }
4627
4628        Ok(report)
4629    }
4630
4631    // ========================================================================
4632    // Deep deletion
4633    // ========================================================================
4634
4635    /// Recursively delete a UTXO and all stale consumed inputs.
4636    ///
4637    /// Only deletes if the output has no remaining consumers (consumedBy is empty).
4638    /// Then recurses into outputsConsumed, removing the deleted output from their
4639    /// consumedBy lists and deleting them if they become unreferenced.
4640    #[allow(dead_code)] // Used by submit() once BEEF parsing is wired up
4641    ///
4642    /// `bound` is a finalize submit's [`CallBound`] over each call here
4643    /// (bsv-low #559); `None` everywhere else.
4644    fn delete_utxo_deep<'a>(
4645        &'a self,
4646        output: &'a Output,
4647        bound: Option<&'a CallBound>,
4648    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4649        Box::pin(async move {
4650            // Only delete if nothing consumes this output
4651            if output.consumed_by.is_empty() {
4652                stored(
4653                    bound,
4654                    self.storage
4655                        .delete_output(&output.txid, output.output_index, &output.topic),
4656                )
4657                .await
4658                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4659
4660                // Notify lookup services. A hook error is dropped, not
4661                // reported (parity: the reference catches it, best effort).
4662                for ls in self.lookup_services.values() {
4663                    let _ = hooked(
4664                        bound,
4665                        ls.output_no_longer_retained_in_history(
4666                            &output.txid,
4667                            output.output_index,
4668                            &output.topic,
4669                        ),
4670                    )
4671                    .await;
4672                }
4673            }
4674
4675            // Recurse into consumed outputs
4676            for consumed in &output.outputs_consumed {
4677                if let Ok(Some(mut stale_output)) = stored(
4678                    bound,
4679                    self.storage.find_output(
4680                        &consumed.txid,
4681                        consumed.output_index,
4682                        Some(&output.topic),
4683                        None,
4684                        false,
4685                    ),
4686                )
4687                .await
4688                {
4689                    // Remove the deleted output from consumedBy
4690                    stale_output.consumed_by.retain(|c| {
4691                        !(c.txid == output.txid && c.output_index == output.output_index)
4692                    });
4693
4694                    stored(
4695                        bound,
4696                        self.storage.update_consumed_by(
4697                            &consumed.txid,
4698                            consumed.output_index,
4699                            &output.topic,
4700                            &stale_output.consumed_by,
4701                        ),
4702                    )
4703                    .await
4704                    .map_err(|e| EngineError::StorageError(e.to_string()))?;
4705
4706                    // Recurse
4707                    let _ = self.delete_utxo_deep(&stale_output, bound).await;
4708                }
4709            }
4710
4711            Ok(())
4712        }) // Box::pin
4713    }
4714
4715    // ========================================================================
4716    // Eviction
4717    // ========================================================================
4718
4719    /// Evict a specific outpoint from the overlay.
4720    ///
4721    /// 1. Deletes the output from storage for the given topic.
4722    /// 2. Notifies all lookup services via `output_evicted()`.
4723    ///
4724    /// If no topic is specified, finds all outputs for the txid and evicts the
4725    /// matching outputIndex across every topic.
4726    ///
4727    /// Matches TS OverlayExpress `/admin/evictOutpoint` (lines 974-1004).
4728    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): RE-NOTIFY every lookup service
4729    /// of the outputs the engine ALREADY admitted for (`txid`, `topic`).
4730    ///
4731    /// Phase-1 dedup means a (txid, topic) once recorded in
4732    /// `applied_transactions` never re-enters through `/submit`, so a lookup
4733    /// service that missed — or mis-parsed — its admit notification could
4734    /// never be told again (the loop-2 subject trap: the pot index's admit
4735    /// hook shape-checked a wire-last hop and wrote no record while the engine
4736    /// had admitted the pot). This replays `output_admitted_by_topic` from the
4737    /// engine's OWN stored outputs (with their BEEF) and the subject-NAMED body
4738    /// exactly as `submit_with_report` sends it. It validates nothing anew
4739    /// (the topic manager judged these outputs at admit), writes none of the
4740    /// engine's tables, and is idempotent for every lookup service whose
4741    /// writes are (the pot index's `store_record` is insert-if-absent for the
4742    /// spend fields — re-admission never regresses spend state).
4743    /// `outputs == 0` means the engine never admitted this txid on this topic:
4744    /// nothing to re-notify — the caller re-SUBMITS instead.
4745    pub async fn renotify_admitted(
4746        &self,
4747        txid: &str,
4748        topic: &str,
4749    ) -> Result<RenotifyReport, EngineError> {
4750        let txid = txid.to_ascii_lowercase();
4751        let outputs = self
4752            .storage
4753            .find_outputs_for_transaction(&txid, true)
4754            .await
4755            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4756        let mut report = RenotifyReport::default();
4757        for output in outputs.iter().filter(|o| o.topic == topic) {
4758            report.outputs += 1;
4759            report.vouts.push(output.output_index);
4760            // the body every WholeTx service receives NAMES the subject (BRC-95
4761            // atomic prefix) — the same rule as the live submit path
4762            let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4763                beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4764                    .ok()
4765                    .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4766                    .unwrap_or_else(|| beef.clone())
4767            });
4768            for (name, ls) in &self.lookup_services {
4769                let payload = match ls.admission_mode() {
4770                    AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4771                        txid: txid.clone(),
4772                        output_index: output.output_index,
4773                        topic: topic.to_string(),
4774                        satoshis: output.satoshis,
4775                        locking_script: output.output_script.clone(),
4776                        off_chain_values: None,
4777                    },
4778                    AdmissionMode::WholeTx => {
4779                        let Some(atomic_beef) = subject_named.as_ref() else {
4780                            report.faults.push(format!(
4781                                "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4782                                output.output_index
4783                            ));
4784                            continue;
4785                        };
4786                        OutputAdmittedByTopic::WholeTx {
4787                            atomic_beef: atomic_beef.clone(),
4788                            output_index: output.output_index,
4789                            topic: topic.to_string(),
4790                            off_chain_values: None,
4791                        }
4792                    }
4793                };
4794                match ls.output_admitted_by_topic(&payload).await {
4795                    Ok(()) => report.notified += 1,
4796                    Err(e) => report.faults.push(format!("{name}: {e}")),
4797                }
4798            }
4799        }
4800        Ok(report)
4801    }
4802
4803    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): FORGET a PHANTOM applied row —
4804    /// (txid, topic) recorded in `applied_transactions` while the topic holds
4805    /// NO output of that txid (the engine judged the tx and admitted nothing:
4806    /// the loop-2 JOIN `f9e85aab…` under the pre-D5 subject rule). Such a row
4807    /// does nothing but block a correct re-submit through the Phase-1 dedup
4808    /// (`/admin/readmit` on beta answered `deduped: ["tm_pot_beta"]` and the
4809    /// pot stayed `known:false`). A row with ANY stored output on the topic is
4810    /// a real admission and is never touched (returns false, as does a missing
4811    /// row). The forgotten bytes are then re-VALIDATED by the topic manager
4812    /// like any first submit — nothing is admitted here.
4813    pub async fn forget_phantom_applied(
4814        &self,
4815        txid: &str,
4816        topic: &str,
4817    ) -> Result<bool, EngineError> {
4818        let txid = txid.to_ascii_lowercase();
4819        let outputs = self
4820            .storage
4821            .find_outputs_for_transaction(&txid, false)
4822            .await
4823            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4824        if outputs.iter().any(|o| o.topic == topic) {
4825            return Ok(false);
4826        }
4827        let rec = AppliedTransaction {
4828            txid: txid.clone(),
4829            topic: topic.to_string(),
4830        };
4831        let exists = self
4832            .storage
4833            .does_applied_transaction_exist(&rec)
4834            .await
4835            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4836        if !exists {
4837            return Ok(false);
4838        }
4839        self.storage
4840            .delete_applied_transaction(&rec)
4841            .await
4842            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4843        Ok(true)
4844    }
4845
4846    pub async fn evict_output(
4847        &self,
4848        txid: &str,
4849        output_index: u32,
4850        topic: Option<&str>,
4851    ) -> Result<(), EngineError> {
4852        if let Some(topic) = topic {
4853            // Delete from storage
4854            self.storage
4855                .delete_output(txid, output_index, topic)
4856                .await
4857                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4858
4859            // Notify all lookup services
4860            for ls in self.lookup_services.values() {
4861                let _ = ls.output_evicted(txid, output_index).await;
4862            }
4863        } else {
4864            // No topic specified — find all outputs for this txid and evict matching ones
4865            let outputs = self
4866                .storage
4867                .find_outputs_for_transaction(txid, false)
4868                .await
4869                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4870
4871            for output in &outputs {
4872                if output.output_index == output_index {
4873                    self.storage
4874                        .delete_output(txid, output_index, &output.topic)
4875                        .await
4876                        .map_err(|e| EngineError::StorageError(e.to_string()))?;
4877                }
4878            }
4879
4880            // Notify all lookup services once
4881            for ls in self.lookup_services.values() {
4882                let _ = ls.output_evicted(txid, output_index).await;
4883            }
4884        }
4885
4886        Ok(())
4887    }
4888
4889    // ========================================================================
4890    // GASP sync
4891    // ========================================================================
4892
4893    /// Start GASP synchronization with peers for all configured topics.
4894    ///
4895    /// For each topic in `sync_configuration`:
4896    /// - `SyncTarget::Ship`: unions local SHIP records, network discovery via
4897    ///   configured SLAP trackers, and the topic's bootstrap peers.
4898    /// - `SyncTarget::Peers(urls)` — uses the provided peer URLs directly.
4899    /// - `SyncTarget::Disabled` — skips the topic.
4900    ///
4901    /// Filters out our own `hosting_url` to avoid self-sync.
4902    ///
4903    /// Returns a `GASPSyncResult` summarizing discovered peers per topic.
4904    ///
4905    /// Discovers peers for each configured topic and runs GASP sync with each.
4906    ///
4907    /// When a `GASPRemoteFactory` is set (via `set_gasp_remote_factory`), this
4908    /// method creates `OverlayGASPStorage` + `GASPRemote` instances per
4909    /// (topic, peer) pair and runs `GASPSync::sync()` to exchange UTXOs.
4910    ///
4911    /// Without a factory, peer discovery still runs but no actual sync occurs
4912    /// (backwards-compatible with the previous discovery-only behavior).
4913    pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
4914        use crate::gasp::{GASPSync, ENGINE_GASP_SYNC_PAGE_LIMIT};
4915        use crate::gasp_overlay::OverlayGASPStorage;
4916
4917        if self.config.sync_configuration.is_empty() {
4918            info!("[GASP SYNC] No sync configuration — nothing to sync");
4919            return Ok(GASPSyncResult {
4920                topics_synced: HashMap::new(),
4921            });
4922        }
4923
4924        let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
4925        // Resolve SLAP providers once per invocation, sharing their host cap
4926        // across all SHIP topics. Every topic still queries each lookup host.
4927        let mut ship_lookup_hosts = None;
4928        let mut topic_order = sync_order(&self.config.sync_configuration);
4929        // Explicit bootstrap-backed SHIP topics run ahead of large tracker
4930        // imports. Stable sorting preserves the existing order elsewhere.
4931        topic_order.sort_by_key(|(topic, target)| {
4932            !(matches!(target, SyncTarget::Ship)
4933                && self
4934                    .ship_bootstrap_peers
4935                    .get(topic.as_str())
4936                    .is_some_and(|peers| !peers.is_empty()))
4937        });
4938
4939        for (topic, target) in topic_order {
4940            // A CONFIGURED topic's peers are reserved part of a storage's
4941            // deferred-graph ceiling (bsv-low #555, the delta-2 fold's D2-M2).
4942            let configured_peers = matches!(target, SyncTarget::Peers(_));
4943            let (peers, sync_type) = match target {
4944                SyncTarget::Disabled => {
4945                    info!("[GASP SYNC] Topic {topic} is disabled — skipping");
4946                    continue;
4947                }
4948                SyncTarget::Ship => {
4949                    info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
4950                    let peers = self
4951                        .discover_ship_peers(topic, &mut ship_lookup_hosts)
4952                        .await;
4953                    info!(
4954                        "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
4955                        peers.len()
4956                    );
4957                    (peers, "ship".to_string())
4958                }
4959                SyncTarget::Peers(peer_urls) => {
4960                    info!(
4961                        "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
4962                        peer_urls.len()
4963                    );
4964                    let peers: Vec<String> = peer_urls
4965                        .iter()
4966                        .filter(|url| {
4967                            if let Some(ref our_url) = self.config.hosting_url {
4968                                url.trim_end_matches('/') != our_url.trim_end_matches('/')
4969                            } else {
4970                                true
4971                            }
4972                        })
4973                        .cloned()
4974                        .collect();
4975
4976                    info!(
4977                        "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
4978                        peers.len()
4979                    );
4980                    (peers, "peers".to_string())
4981                }
4982            };
4983
4984            let mut errors = Vec::new();
4985            let mut pruned_inputs: u64 = 0;
4986            let mut discarded_graphs: u64 = 0;
4987            let mut anchor_walk_held_graphs: u64 = 0;
4988            let mut finalized_graphs: u64 = 0;
4989            let mut deadline_dropped_graphs: u64 = 0;
4990            let mut cursor_moves: Vec<CursorMove> = Vec::new();
4991            let mut deferred_graphs: u64 = 0;
4992            let mut resumed_graphs: u64 = 0;
4993            let mut converged_graphs: u64 = 0;
4994            let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
4995            let mut stalled_graphs: u64 = 0;
4996            let mut held_back_graphs: u64 = 0;
4997
4998            // If we have a remote factory, actually run GASP sync
4999            if let Some(ref factory) = self.gasp_remote_factory {
5000                for peer_url in &peers {
5001                    // bsv-low#302 quarantine gate: a peer at
5002                    // PEER_QUARANTINE_THRESHOLD consecutive failures is
5003                    // SKIPPED (no attempt recorded — its last-attempt age
5004                    // keeps growing) until the re-probe window opens. A
5005                    // health-read FAULT treats the peer as healthy: broken
5006                    // bookkeeping must never silence a live peer.
5007                    //
5008                    // The peer's health (its quarantine, its yieldless
5009                    // streak) is keyed on its NORMALIZED ORIGIN (bsv-low
5010                    // #555, the delta-2 fold's D2-M2): keyed on the URL, one
5011                    // server was a fresh peer for every spelling it
5012                    // advertised.
5013                    let health_key = crate::gasp::peer_origin(peer_url);
5014                    let health = self
5015                        .storage
5016                        .get_peer_sync_health(&health_key, topic)
5017                        .await
5018                        .unwrap_or_default();
5019                    if crate::gasp::peer_sync_quarantined(&health) {
5020                        warn!(
5021                            "[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)",
5022                            health.consecutive_failures,
5023                            health.secs_since_last_attempt,
5024                            crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5025                        );
5026                        continue;
5027                    }
5028
5029                    info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
5030
5031                    // Get last interaction score for this (peer, topic) pair
5032                    let last_interaction = self
5033                        .storage
5034                        .get_last_interaction(peer_url, topic)
5035                        .await
5036                        .unwrap_or(0);
5037
5038                    // Create shared sink for finalized graphs
5039                    let sink = crate::gasp_overlay::new_finalized_graph_sink();
5040
5041                    // OPT-IN ancestor hydration: only when a fetcher is
5042                    // configured do we enable chain-backed ancestor fallback +
5043                    // strict-BEEF finalize. Default (None) = byte-identical to
5044                    // today (tolerant BEEF, peer errors abandon the graph;
5045                    // the one exception is the D8 prune of a manager-named
5046                    // input the peer answers it does not hold, which exists
5047                    // only WITHOUT a fetcher, see
5048                    // `GASPSync::process_incoming_node`).
5049                    let hydration_on = self.ancestor_fetcher.is_some();
5050
5051                    // Create storage adapter and remote
5052                    let mut gasp_storage =
5053                        OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5054                            .with_peer(peer_url.as_str())
5055                            .with_configured_peer(configured_peers)
5056                            .with_strict_beef(hydration_on)
5057                            .with_script_verification(self.verify_scripts)
5058                            .with_walk_budget(self.walk_budget);
5059                    if let Some(manager) = self.managers.get(topic) {
5060                        gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5061                    }
5062                    // bsv-low #551: the anchor check verifies each graph's
5063                    // root against THIS engine's tracker, with this engine's
5064                    // script switch, before finalize. No tracker is the
5065                    // reference's 'scripts only'.
5066                    if let Some(tracker) = self.chain_tracker.as_deref() {
5067                        gasp_storage = gasp_storage.with_chain_tracker(tracker);
5068                    }
5069                    let gasp_remote = factory.create_remote(peer_url, topic);
5070
5071                    let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5072                    // Graphs of this peer handed to submit (bsv-low #552).
5073                    let peer_finalized = std::cell::Cell::new(0u64);
5074                    let mut sync = GASPSync::new(
5075                        Box::new(gasp_storage),
5076                        gasp_remote,
5077                        last_interaction,
5078                        &log_prefix,
5079                        true, // unidirectional — overlay GASP is pull-only (submitNode throws); matches TS Engine.startGASPSync
5080                    )
5081                    .with_ancestor_fetcher(self.ancestor_fetcher.clone());
5082                    // bsv-low #555: a graph past its own budget is deferred,
5083                    // not dropped with the sync.
5084                    if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5085                        let (sleep, budget_ms) = (sleep.clone(), *budget_ms);
5086                        let half_sleep = sleep.clone();
5087                        sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5088                            max_calls: *max_calls,
5089                            max_bytes_fetched: self.graph_budget_limbs.0,
5090                            max_nodes: self.graph_budget_limbs.1,
5091                            deadline: Box::new(move || sleep(budget_ms)),
5092                            half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5093                        });
5094                    }
5095                    // bsv-low #552: under a budget each graph is submitted as
5096                    // it finalizes, so the deadline cannot take it back. With
5097                    // no budget nothing can drop the sync, and the graphs are
5098                    // submitted after it as they always were.
5099                    if self.peer_sync_budget.is_some() {
5100                        sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5101                            engine: self,
5102                            sink: sink.clone(),
5103                            peer_url,
5104                            topic,
5105                            submitted: &peer_finalized,
5106                        }));
5107                    }
5108
5109                    // bsv-low#302: bound THIS peer's sync to the configured
5110                    // budget. `None` from the race = the budget won and the
5111                    // sync future was dropped mid-flight. What it finalized
5112                    // before that is already submitted (the hook above); the
5113                    // cursor then advances to `completed_cursor` only, see
5114                    // the deadline arm below. The race is GUARDED: it never
5115                    // drops the sync inside one transaction's finalize
5116                    // submit (the lens fold's HIGH-1), it waits for that
5117                    // transaction and the hook stops at the boundary.
5118                    let sync_outcome = match &self.peer_sync_budget {
5119                        Some((sleep, budget_ms)) => {
5120                            crate::gasp::race_or_deadline_guarded(
5121                                sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5122                                sleep(*budget_ms),
5123                                &self.finalize_gate,
5124                            )
5125                            .await
5126                        }
5127                        None => Some(sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)).await),
5128                    };
5129
5130                    let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5131                    // D8 decoy rule: branches pruned in this peer's walk. Not
5132                    // an error and not a failed UTXO, so it is counted apart
5133                    // from `errors` and never touches the outcome or cursor.
5134                    // Summed over peers: one decoy seen through two peers
5135                    // counts twice.
5136                    pruned_inputs += sync.pruned_inputs();
5137                    // bsv-low #551: graphs the anchor check refused. Counted
5138                    // apart from `errors` for the same reason as a prune: a
5139                    // refused graph is not a failed sync (the reference
5140                    // discards it and carries on).
5141                    discarded_graphs += sync.discarded_graphs();
5142                    anchor_walk_held_graphs += sync.anchor_walk_held();
5143                    // Whether the sync ran (to its end, or to the deadline)
5144                    // and whether it moved the cursor (bsv-low #555, the
5145                    // delta fold's D-M2).
5146                    let ran = !matches!(sync_outcome, Some(Err(_)));
5147                    let mut advanced_cursor = false;
5148                    match sync_outcome {
5149                        None => {
5150                            let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5151                            let msg = format!(
5152                                "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5153                            );
5154                            warn!("[GASP SYNC] {msg}");
5155                            errors.push(msg);
5156
5157                            // bsv-low #552: the work COMPLETED before the
5158                            // deadline is kept. Its graphs are already
5159                            // submitted (the hook); the cursor moves past the
5160                            // UTXOs that were completed and stops below the
5161                            // one in flight, whose graph is lost whole and
5162                            // walked again next tick. A graph the deadline
5163                            // met while it was being SUBMITTED is not "in
5164                            // flight" here: it left a prefix of whole
5165                            // transactions, is counted finalized, and its
5166                            // UTXO is below the cursor too.
5167                            let in_flight = u64::from(sync.graph_in_flight());
5168                            deadline_dropped_graphs += in_flight;
5169                            // bsv-low #555: the walk in hand is saved, so the
5170                            // next tick resumes it instead of walking it again.
5171                            sync.defer_in_flight().await;
5172                            let completed = sync.completed_cursor();
5173                            if completed > last_interaction {
5174                                match self
5175                                    .storage
5176                                    .update_last_interaction(peer_url, topic, completed)
5177                                    .await
5178                                {
5179                                    Ok(()) => cursor_moves.push(CursorMove {
5180                                        peer: peer_url.clone(),
5181                                        from: last_interaction,
5182                                        to: completed,
5183                                    }),
5184                                    Err(e) => warn!(
5185                                        "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5186                                    ),
5187                                }
5188                            }
5189                            // A tick that finalized a graph or moved the
5190                            // cursor reached a live peer: not a failed
5191                            // attempt for the quarantine count. So did one
5192                            // whose walk PROGRESSED (bsv-low #555: it
5193                            // appended a node, or completed its graph). A
5194                            // deferral that fetched nothing is no progress
5195                            // (the lens fold's H1: a hung peer deferred with
5196                            // 0 nodes on every tick and was never
5197                            // quarantined).
5198                            advanced_cursor = completed > last_interaction;
5199                            outcome_success = peer_finalized.get() > 0
5200                                || completed > last_interaction
5201                                || sync.deferral_stats().progressed > 0;
5202                            warn!(
5203                                "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5204                                peer_finalized.get(),
5205                                completed.max(last_interaction)
5206                            );
5207                        }
5208                        Some(Ok(())) => {
5209                            // Submit finalized graphs to the Engine (under a
5210                            // budget the hook already did, graph by graph,
5211                            // and the sink is empty here).
5212                            let landed = self
5213                                .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5214                                .await;
5215                            // bsv-low #555, the lens fold's H1: a sync that
5216                            // ran to its end with walks the per-graph budget
5217                            // cut having FETCHED NOTHING, and nothing else
5218                            // got done (no walk progressed, no graph
5219                            // finalized, the cursor did not move), reached
5220                            // a peer that does not answer: a FAILED attempt.
5221                            // Before #555 such a peer held the sync to the
5222                            // per-peer deadline, a failure; the per-graph
5223                            // budget had made it an `Ok` sync. A sync whose
5224                            // UTXOs failed in any other way is unchanged.
5225                            let stats = sync.deferral_stats();
5226                            if stats.stalled > 0
5227                                && stats.progressed == 0
5228                                && peer_finalized.get() == 0
5229                                && !(landed && sync.last_interaction > last_interaction)
5230                            {
5231                                outcome_success = false;
5232                                let msg = format!(
5233                                    "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5234                                    stats.stalled
5235                                );
5236                                warn!("[GASP SYNC] {msg}");
5237                                errors.push(msg);
5238                            }
5239
5240                            // Advance the persisted cursor when it moved forward — i.e.
5241                            // the peer reported UTXOs at a higher score than our last
5242                            // `since` (the in-memory cursor only grows on SEEN scores,
5243                            // so `>` already excludes the empty-bootstrap case where no
5244                            // UTXOs were seen). Mirrors TS `if (gasp.lastInteraction >
5245                            // lastInteraction)` — NOT gated on a new submission: a sync
5246                            // that re-sees already-known UTXOs must still advance, else
5247                            // every cron re-scans the same range forever (the cursor
5248                            // stayed pinned at 0 in prod).
5249                            //
5250                            // NOT advanced when a finalize submit above did
5251                            // not land (the lens fold's MEDIUM-1): the sink
5252                            // does not say which UTXO that graph was, so the
5253                            // whole cursor stays and the next sync is served
5254                            // the range again (what did land is known and
5255                            // skipped). Under a budget the hook fails that
5256                            // one UTXO instead and the gap guard has already
5257                            // capped `sync.last_interaction` below it.
5258                            advanced_cursor = landed && sync.last_interaction > last_interaction;
5259                            if !landed {
5260                                warn!(
5261                                    "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5262                                );
5263                            } else if sync.last_interaction > last_interaction {
5264                                match self
5265                                    .storage
5266                                    .update_last_interaction(peer_url, topic, sync.last_interaction)
5267                                    .await
5268                                {
5269                                    Ok(()) => cursor_moves.push(CursorMove {
5270                                        peer: peer_url.clone(),
5271                                        from: last_interaction,
5272                                        to: sync.last_interaction,
5273                                    }),
5274                                    Err(e) => warn!(
5275                                        "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5276                                    ),
5277                                }
5278                            }
5279                            info!(
5280                                "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5281                                sync.last_interaction
5282                            );
5283                        }
5284                        Some(Err(e)) => {
5285                            let msg = format!("{peer_url}: {e}");
5286                            warn!("[GASP SYNC] Sync failed: {msg}");
5287                            errors.push(msg);
5288                        }
5289                    }
5290                    // bsv-low #555, the delta fold's D-M2: "progress" (a node
5291                    // appended) is the peer's word. A sync the peer served
5292                    // work that finalized no graph and moved no cursor is
5293                    // YIELDLESS; past PEER_YIELDLESS_SYNCS_ALLOWED of them in
5294                    // a row each is a FAILED attempt, so a peer that serves
5295                    // one fresh root a tick and hangs on its input is
5296                    // quarantined. A quiet sync (nothing served) leaves the
5297                    // count; a fault of the count is fail-safe (no failure).
5298                    // Only with a per-graph budget: without one nothing is
5299                    // deferred and #302's rule is unchanged.
5300                    if ran && self.graph_budget.is_some() {
5301                        let yielded = peer_finalized.get() > 0 || advanced_cursor;
5302                        if yielded || sync.graphs_attempted() > 0 {
5303                            match self
5304                                .storage
5305                                .record_peer_sync_yield(&health_key, topic, yielded)
5306                                .await
5307                            {
5308                                Ok(streak)
5309                                    if !yielded
5310                                        && crate::gasp::yieldless_sync_failed(&streak)
5311                                        && outcome_success =>
5312                                {
5313                                    outcome_success = false;
5314                                    let msg = format!(
5315                                        "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5316                                        streak.yieldless_syncs,
5317                                        streak.secs_since_first.unwrap_or(0),
5318                                        crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5319                                        crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5320                                    );
5321                                    warn!("[GASP SYNC] {msg}");
5322                                    errors.push(msg);
5323                                }
5324                                Ok(_) => {}
5325                                Err(e) => warn!(
5326                                    "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5327                                ),
5328                            }
5329                        }
5330                    }
5331                    finalized_graphs += peer_finalized.get();
5332                    let deferral = sync.deferral_stats();
5333                    deferred_graphs += deferral.deferred;
5334                    resumed_graphs += deferral.resumed;
5335                    converged_graphs += deferral.converged;
5336                    stalled_graphs += deferral.stalled;
5337                    held_back_graphs += deferral.held_back;
5338                    dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5339                        peer: peer_url.clone(),
5340                        outpoint: d.outpoint,
5341                        reason: d.reason.as_str().to_string(),
5342                    }));
5343
5344                    // bsv-low#302: record the attempt outcome (success resets
5345                    // the consecutive-failure count; timeout/error increments
5346                    // it). Best-effort — a bookkeeping fault must never fail
5347                    // the sync pass itself.
5348                    //
5349                    // bsv-low #555, the delta-4 fold's D4-L2: with a
5350                    // per-graph budget, a peer that was at the quarantine
5351                    // threshold when this sync began is re-admitted only by
5352                    // a YIELD (a graph finalized, the cursor moved). A sync
5353                    // that did not fail and yielded nothing (an empty
5354                    // listing at its probe, a walk that only progressed) is
5355                    // NOT RECORDED: the failures stay, and so does the
5356                    // last-attempt stamp, so the peer is attended again on
5357                    // the next tick and its next failure re-arms the
5358                    // quarantine at once. Recorded as a success, a hostile
5359                    // peer's empty listing at each probe reset its failures
5360                    // and (the decay being for a peer that is not failed)
5361                    // its yieldless streak: 100 of 200 ticks. A health read
5362                    // that faulted read 0 failures: the old rule, fail-safe.
5363                    if outcome_success
5364                        && !(peer_finalized.get() > 0 || advanced_cursor)
5365                        && self.graph_budget.is_some()
5366                        && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5367                    {
5368                        info!(
5369                            "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5370                            health.consecutive_failures
5371                        );
5372                    } else if let Err(e) = self
5373                        .storage
5374                        .record_peer_sync_outcome(&health_key, topic, outcome_success)
5375                        .await
5376                    {
5377                        warn!(
5378                            "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5379                        );
5380                    }
5381                }
5382            }
5383
5384            topics_synced.insert(
5385                topic.clone(),
5386                TopicSyncResult {
5387                    peers,
5388                    sync_type,
5389                    errors,
5390                    pruned_inputs,
5391                    discarded_graphs,
5392                    anchor_walk_held_graphs,
5393                    finalized_graphs,
5394                    deadline_dropped_graphs,
5395                    cursor_moves,
5396                    deferred_graphs,
5397                    resumed_graphs,
5398                    converged_graphs,
5399                    dropped_graphs,
5400                    stalled_graphs,
5401                    held_back_graphs,
5402                },
5403            );
5404        }
5405
5406        info!(
5407            "[GASP SYNC] Peer discovery complete for {} topic(s)",
5408            topics_synced.len()
5409        );
5410
5411        Ok(GASPSyncResult { topics_synced })
5412    }
5413
5414    /// Drain `sink` and submit every finalized graph in it, ancestors first
5415    /// (`historical-tx-no-spv`: the anchor check already verified them).
5416    /// Each graph is added to `submitted` as its first submit starts. Returns
5417    /// whether EVERY transaction of every graph landed.
5418    ///
5419    /// Each graph here passed the anchor check: its root verified and the
5420    /// replay admitted it, so these submits rest on a checked premise and are
5421    /// not expected to fail. If one does not LAND, the REST OF THAT GRAPH is
5422    /// not submitted: the reference's `finalizeGraph` awaits each submit in
5423    /// order and its throw ends the loop (the BEEFs already submitted stay,
5424    /// it has no rollback either). A later BEEF would otherwise be judged
5425    /// without the coin its ancestor failed to leave, admit nothing and be
5426    /// recorded as applied, a dupe for good. "Landed" is read from the
5427    /// durability report, not from `Ok`: `submit` answers `Ok` when a storage
5428    /// write faulted and when the topic manager failed, and in both it
5429    /// records no applied row. A transaction landed when its topic is applied
5430    /// or was already applied (a dupe). Other graphs carry on, and the caller
5431    /// keeps the cursor from passing a graph that did not land.
5432    ///
5433    /// Each transaction's submit is one write section of the engine's gate
5434    /// ([`Engine::finalize_submit_gate`]) and the gate is asked between two:
5435    /// a deadline that is due stops the sequence there (this future is
5436    /// dropped at that await, between two whole transactions).
5437    async fn submit_finalized_graphs(
5438        &self,
5439        sink: &crate::gasp_overlay::FinalizedGraphSink,
5440        peer_url: &str,
5441        topic: &str,
5442        submitted: &std::cell::Cell<u64>,
5443    ) -> bool {
5444        let finalized: Vec<_> = sink
5445            .lock()
5446            .unwrap_or_else(std::sync::PoisonError::into_inner)
5447            .drain(..)
5448            .collect();
5449
5450        let mut all_landed = true;
5451        for graph in &finalized {
5452            for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5453                self.finalize_gate.stop_if_due().await;
5454                if position == 0 {
5455                    submitted.set(submitted.get() + 1);
5456                }
5457                let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5458                // No deadline drops a write section, and the section does
5459                // not drop ITSELF either (bsv-low #559, the delta lens's
5460                // DELTA-3 and the lens fold of 2026-10-07, F2,
5461                // `set_finalize_submit_budget`): the submit always runs to
5462                // its end. What is bounded is each storage call and hook
5463                // inside it ([`CallBound`]): one that never answers is that
5464                // call's FAULT, the submit handles it as any fault (nothing
5465                // of the transaction held, or all of it), the transaction
5466                // did not land, its UTXO fails and the cursor stays.
5467                let outcome = {
5468                    let _writing = self.finalize_gate.write_section();
5469                    let bound = self
5470                        .finalize_submit_budget
5471                        .as_ref()
5472                        .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5473                    self.submit_bounded(
5474                        &tagged,
5475                        SubmitMode::HistoricalTxNoSpv,
5476                        bound.as_ref(),
5477                        true,
5478                        WalkBreachPolicy::NotNow,
5479                    )
5480                    .await
5481                };
5482                let not_landed = match outcome {
5483                    Ok((_, report)) => {
5484                        let landed = report.is_durable()
5485                            && (report.applied_topics.contains(&graph.topic)
5486                                || report.deduped_topics.contains(&graph.topic));
5487                        (!landed).then(|| {
5488                            if report.is_durable() {
5489                                "the topic manager failed on it".to_string()
5490                            } else {
5491                                format!("not durable: {}", report.summary())
5492                            }
5493                        })
5494                    }
5495                    Err(e) => Some(e.to_string()),
5496                };
5497                if let Some(why) = not_landed {
5498                    warn!(
5499                        "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
5500                        graph.topic
5501                    );
5502                    all_landed = false;
5503                    break;
5504                }
5505            }
5506        }
5507
5508        if !finalized.is_empty() {
5509            info!(
5510                "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
5511                finalized.len()
5512            );
5513        }
5514        all_landed
5515    }
5516
5517    /// Union local records, tracker answers, SLAP-named providers and bootstrap.
5518    /// Reference: overlay-services@480d77a src/Engine.ts:839-876 and
5519    /// ts-stack@edf6e03 packages/sdk/src/overlay-tools/LookupResolver.ts:1976.
5520    async fn discover_ship_peers(
5521        &self,
5522        topic: &str,
5523        lookup_hosts: &mut Option<Vec<String>>,
5524    ) -> Vec<String> {
5525        let mut domains = self.local_ship_peers(topic).await;
5526        if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
5527            domains.extend(bootstrap.iter().cloned());
5528        }
5529        if let Some(client) = &self.ship_lookup_client {
5530            if lookup_hosts.is_none() {
5531                *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
5532            }
5533            let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
5534            for host in lookup_hosts.as_ref().into_iter().flatten() {
5535                match client.lookup(host, &question).await {
5536                    Ok(outputs) => {
5537                        domains.extend(outputs.iter().filter_map(|output| {
5538                            parse_lookup_advertisement(output, b"SHIP", topic)
5539                        }));
5540                    }
5541                    Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
5542                }
5543            }
5544        }
5545        self.ship_peers_excluding_self(domains)
5546    }
5547
5548    async fn discover_ship_lookup_hosts(
5549        &self,
5550        client: &dyn crate::ship_lookup::ShipLookupClient,
5551    ) -> Vec<String> {
5552        use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
5553        let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
5554        let tracker_origins: HashSet<_> = trackers
5555            .iter()
5556            .map(|host| crate::gasp::peer_origin(host))
5557            .collect();
5558        let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
5559        let mut advertised = Vec::new();
5560        for tracker in &trackers {
5561            match client.lookup(tracker, &question).await {
5562                Ok(outputs) => {
5563                    advertised.extend(outputs.iter().filter_map(|output| {
5564                        parse_lookup_advertisement(output, b"SLAP", "ls_ship")
5565                    }));
5566                }
5567                Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
5568            }
5569        }
5570        let providers = self
5571            .ship_peers_excluding_self(advertised)
5572            .into_iter()
5573            .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
5574            .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
5575        trackers.into_iter().chain(providers).collect()
5576    }
5577
5578    fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
5579        crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
5580            self.config.hosting_url.as_ref().is_none_or(|our_url| {
5581                crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
5582            })
5583        }))
5584    }
5585
5586    /// Read local SHIP records independently of network discovery failures.
5587    async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
5588        let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
5589            warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
5590            return Vec::new();
5591        };
5592
5593        let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
5594
5595        let refs = match ship_ls.lookup(&question).await {
5596            Ok(LookupResult::OutputList(refs)) => refs,
5597            Ok(LookupResult::Answer(_)) => {
5598                error!(
5599                    "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
5600                     LookupAnswer; expected OutputList. Skipping."
5601                );
5602                return Vec::new();
5603            }
5604            Err(e) => {
5605                error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
5606                return Vec::new();
5607            }
5608        };
5609
5610        let mut domains = HashSet::new();
5611        for reference in &refs {
5612            if let Ok(Some(output)) = self
5613                .storage
5614                .find_output(&reference.txid, reference.output_index, None, None, false)
5615                .await
5616            {
5617                if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
5618                    // Every spelling of our own origin is ourselves (the
5619                    // delta-2 fold's D2-M2: `https://us/?x` passed the
5620                    // string compare).
5621                    if let Some(ref our_url) = self.config.hosting_url {
5622                        if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
5623                            || crate::gasp::peer_origin(&domain)
5624                                == crate::gasp::peer_origin(our_url)
5625                        {
5626                            continue;
5627                        }
5628                    }
5629                    domains.insert(domain);
5630                }
5631            }
5632        }
5633
5634        // One peer per normalized origin (bsv-low #555, the delta-2 fold's
5635        // D2-M2, restored by the delta-4 fold's D4-M1): eight spellings of
5636        // one server are one peer, the one its health row is keyed by.
5637        crate::gasp::ship_peers_by_origin(domains)
5638    }
5639
5640    // ========================================================================
5641    // Helpers
5642    // ========================================================================
5643
5644    /// Get a reference to the storage backend.
5645    pub fn storage(&self) -> &dyn Storage {
5646        self.storage.as_ref()
5647    }
5648
5649    /// Get the engine configuration.
5650    pub fn config(&self) -> &EngineConfig {
5651        &self.config
5652    }
5653}
5654
5655/// The SPV check of [`Engine::submit`], as one function over a BORROWED
5656/// tracker: the reference's `tx.verify(chainTracker)` (roots against the
5657/// tracker, every unproven input's script executed, the value rule), its
5658/// `tx.verify('scripts only')` when `chain_tracker` is `None` (roots accepted
5659/// unchecked, scripts still run), and the pre-2026-09-08 structural check when
5660/// `verify_scripts` is `false` ([`Engine::set_script_verification`]'s escape
5661/// hatch).
5662///
5663/// There is ONE verifier. `Engine::submit` calls this with its own tracker,
5664/// switch and walk budget; the GASP anchor check
5665/// (`OverlayGASPStorage::validate_graph_anchor`, bsv-low #551) calls it with
5666/// the same three, handed over by `Engine::start_gasp_sync`.
5667///
5668/// `Ok(Some(_))` is a walk that COULD NOT RUN within `budget` (bsv-low #592):
5669/// not a verdict on the transaction. The caller goes on as it would without a
5670/// walk and says so.
5671pub(crate) async fn verify_spv_like_the_reference(
5672    chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5673    verify_scripts: bool,
5674    beef_bytes: &[u8],
5675    subject_txid: &str,
5676    budget: DoorBudget,
5677) -> Result<Option<WalkCouldNotRun>, EngineError> {
5678    verify_spv_trusting(
5679        chain_tracker,
5680        verify_scripts,
5681        beef_bytes,
5682        subject_txid,
5683        &HashSet::new(),
5684        budget,
5685    )
5686    .await
5687}
5688
5689/// [`verify_spv_like_the_reference`] trusting the transactions an earlier
5690/// walk of the same submit passed over (a predecessor the door lands first,
5691/// the E1D delta fold, L4): the script walk neither checks nor descends
5692/// them. The structural escape hatch checks the whole BEEF it is given.
5693async fn verify_spv_trusting(
5694    chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5695    verify_scripts: bool,
5696    beef_bytes: &[u8],
5697    subject_txid: &str,
5698    trusted: &HashSet<String>,
5699    budget: DoorBudget,
5700) -> Result<Option<WalkCouldNotRun>, EngineError> {
5701    if verify_scripts {
5702        Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
5703    } else {
5704        Engine::verify_spv_structurally(chain_tracker, beef_bytes)
5705            .await
5706            .map(|()| None)
5707    }
5708}
5709
5710/// The BUMP that proves each txid of `beef`: the first BUMP whose level 0
5711/// carries it, as `Beef::find_bump` picks, built in one pass over the BUMPs'
5712/// leaves (NL-6e; `find_bump` per transaction scans them all each time).
5713fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
5714    let mut proven: HashMap<&str, usize> = HashMap::new();
5715    for (at, bump) in beef.bumps.iter().enumerate() {
5716        for leaf in bump.path.first().into_iter().flatten() {
5717            if let Some(hash) = leaf.hash.as_deref() {
5718                proven.entry(hash).or_insert(at);
5719            }
5720        }
5721    }
5722    proven
5723}
5724
5725/// The transactions the SPV walk of `from` passes over in `beef`
5726/// ([`Engine::verify_beef_linear_with`]'s traversal, nothing checked): `from`
5727/// and every source the BEEF carries, a proven transaction included and not
5728/// descended. The E1D delta fold, L4: what a landing need not walk again.
5729fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
5730    let bodies: HashMap<String, &Transaction> = beef
5731        .txs
5732        .iter()
5733        .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
5734        .collect();
5735    let proven = proven_index(beef);
5736    let mut cover = HashSet::new();
5737    let mut queue = vec![from.to_string()];
5738    while let Some(txid) = queue.pop() {
5739        if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
5740            continue;
5741        }
5742        if let Some(tx) = bodies.get(&txid) {
5743            queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
5744        }
5745    }
5746    cover
5747}
5748
5749/// The engine's [`crate::gasp::FinalizedGraphHook`] (bsv-low #552): submit
5750/// what a peer's sync just finalized, before its next UTXO is asked for.
5751struct SubmitAsFinalized<'e> {
5752    engine: &'e Engine,
5753    sink: crate::gasp_overlay::FinalizedGraphSink,
5754    peer_url: &'e str,
5755    topic: &'e str,
5756    submitted: &'e std::cell::Cell<u64>,
5757}
5758
5759#[async_trait::async_trait(?Send)]
5760impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
5761    async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
5762        // A graph is counted as its first submit starts, and the deadline
5763        // cannot land inside a submit: a sync dropped in here has written at
5764        // least that transaction whole (an ancestors-first prefix).
5765        let landed = self
5766            .engine
5767            .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
5768            .await;
5769        if landed {
5770            Ok(())
5771        } else {
5772            // Fails this UTXO: the cursor stays below it and the next sync
5773            // asks for it again (the lens fold's MEDIUM-1).
5774            Err(crate::gasp::GASPError::StorageError(
5775                "a finalize submit did not land".to_string(),
5776            ))
5777        }
5778    }
5779}
5780
5781/// Result of a GASP sync operation.
5782///
5783/// Returned by `Engine::start_gasp_sync()` to summarize what happened.
5784#[derive(Debug, Clone, Serialize, Deserialize)]
5785pub struct GASPSyncResult {
5786    /// Per-topic sync results.
5787    pub topics_synced: HashMap<String, TopicSyncResult>,
5788}
5789
5790/// Sync result for a single topic.
5791#[derive(Debug, Clone, Serialize, Deserialize)]
5792pub struct TopicSyncResult {
5793    /// Peer URLs discovered or configured for this topic.
5794    pub peers: Vec<String>,
5795    /// How peers were determined: "ship" or "peers".
5796    pub sync_type: String,
5797    /// Any errors encountered during sync (peer URL -> error message): a
5798    /// peer whose sync failed or ran over its budget. A single UTXO whose
5799    /// graph ingest failed is NOT listed here: it is warned (`Error ingesting
5800    /// UTXO`), held back by the cursor gap guard and asked for again by the
5801    /// next sync.
5802    pub errors: Vec<String>,
5803    /// Manager-named inputs pruned because a peer answered that it does not
5804    /// hold them (the D8 decoy rule, `GASPSync::pruned_inputs`). Counted per
5805    /// distinct outpoint PER PEER per sync and summed over this topic's
5806    /// peers: one decoy seen through two peers reads 2. Not errors: the
5807    /// graphs completed without those branches.
5808    #[serde(default)]
5809    pub pruned_inputs: u64,
5810    /// Graphs the anchor check REFUSED and discarded whole (bsv-low #551,
5811    /// `GASPSync::discarded_graphs`): a root whose BEEF does not verify, or
5812    /// one the topic manager does not admit at the end of the replay. Summed
5813    /// over this topic's peers. Not errors, and nothing of them was admitted.
5814    #[serde(default)]
5815    pub discarded_graphs: u64,
5816    /// Graphs HELD because their anchor's script walk could not run within
5817    /// the engine's walk budget (bsv-low #592; the E592 lens fold, H1 and
5818    /// L3; `GASPSync::anchor_walk_held`), summed over this topic's peers.
5819    /// Nothing of them was finalized; each UTXO stays below its cursor until
5820    /// the peer serves its root proven. Not errors and not discarded.
5821    #[serde(default)]
5822    pub anchor_walk_held_graphs: u64,
5823    /// Graphs that passed the anchor check and were handed to submit, summed
5824    /// over this topic's peers (bsv-low #552). Under a per-peer budget a
5825    /// graph is counted, and submitted, the moment it finalizes, so the
5826    /// count includes those of a peer the deadline then dropped. A counted
5827    /// graph had its first transaction submitted whole (the deadline never
5828    /// lands inside a submit); one whose later submit did not land is still
5829    /// counted, and its UTXO stays below the cursor.
5830    /// A graph is counted as its first submit STARTS; a first submit that FAULTS
5831    /// still counts here, so this is not a delivered count (bsv-low #559).
5832    #[serde(default)]
5833    pub finalized_graphs: u64,
5834    /// Graphs that were mid-walk when a peer's sync was dropped at its
5835    /// budget (bsv-low #552): at most one per peer per sync. NOTHING of such
5836    /// a graph was admitted; the next sync walks it again. The same root
5837    /// counted tick after tick with no `finalized_graphs` and no
5838    /// `cursor_moves` is ONE graph whose walk outlasts the budget (with a
5839    /// per-graph budget it is also counted in `deferred_graphs` and resumed,
5840    /// bsv-low #555).
5841    #[serde(default)]
5842    pub deadline_dropped_graphs: u64,
5843    /// Every persisted cursor that moved in this sync (bsv-low #552), one
5844    /// entry per peer. After a completed sync `to` is the peer's highest
5845    /// score seen (less the gap guard); after a deadline it is
5846    /// `GASPSync::completed_cursor`: past completed UTXOs only.
5847    #[serde(default)]
5848    pub cursor_moves: Vec<CursorMove>,
5849    /// Graphs DEFERRED in this sync, summed over peers (bsv-low #555,
5850    /// `Engine::set_graph_budget`): a new record, or a resumed graph
5851    /// deferred again. Their UTXOs are held below the cursor.
5852    #[serde(default)]
5853    pub deferred_graphs: u64,
5854    /// Graphs RESUMED from a record in this sync.
5855    #[serde(default)]
5856    pub resumed_graphs: u64,
5857    /// Resumed graphs that completed and landed in this sync (their record
5858    /// deleted).
5859    #[serde(default)]
5860    pub converged_graphs: u64,
5861    /// Records deleted without converging, with their reason.
5862    #[serde(default)]
5863    pub dropped_graphs: Vec<DroppedDeferral>,
5864    /// Walks the per-graph budget (or the per-peer deadline) cut having
5865    /// fetched NOTHING in their pass (bsv-low #555, the lens fold's H1). A
5866    /// peer's sync whose only outcome was these is a failed attempt for the
5867    /// quarantine count.
5868    #[serde(default)]
5869    pub stalled_graphs: u64,
5870    /// Records NOT resumed in this sync: the resumes of a peer's pass share
5871    /// ONE per-graph budget and had spent it (the lens fold's M1). Held for
5872    /// the next sync, untouched.
5873    #[serde(default)]
5874    pub held_back_graphs: u64,
5875}
5876
5877/// A deferred graph's record deleted without converging (bsv-low #555,
5878/// [`TopicSyncResult::dropped_graphs`]).
5879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5880pub struct DroppedDeferral {
5881    /// The peer URL.
5882    pub peer: String,
5883    /// The graph's root outpoint.
5884    pub outpoint: String,
5885    /// [`crate::gasp::DropReason::as_str`].
5886    pub reason: String,
5887}
5888
5889/// One peer's persisted `last_interaction` cursor moving `from` -> `to` in a
5890/// sync ([`TopicSyncResult::cursor_moves`]).
5891#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5892pub struct CursorMove {
5893    /// The peer URL.
5894    pub peer: String,
5895    /// The cursor the sync entered with.
5896    pub from: u64,
5897    /// The cursor it persisted.
5898    pub to: u64,
5899}
5900
5901/// Get current time in milliseconds (for output scores).
5902///
5903/// Uses `js_sys::Date::now()` on wasm32 (Cloudflare Workers) since
5904/// `std::time::SystemTime` is not available on that platform.
5905fn current_timestamp_ms() -> f64 {
5906    #[cfg(target_arch = "wasm32")]
5907    {
5908        js_sys::Date::now()
5909    }
5910    #[cfg(not(target_arch = "wasm32"))]
5911    {
5912        std::time::SystemTime::now()
5913            .duration_since(std::time::UNIX_EPOCH)
5914            .map_or(0.0, |d| d.as_millis() as f64)
5915    }
5916}
5917
5918/// Extract the domain field from a SHIP PushDrop locking script.
5919///
5920/// SHIP PushDrop format: field[0] = "SHIP", field[1] = identity_key,
5921/// field[2] = domain (UTF-8), field[3] = topic.
5922/// Returns `None` if the script cannot be parsed or is not a SHIP advertisement.
5923fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
5924    parse_advertisement_domain_from_script(output_script, b"SHIP", None)
5925}
5926
5927fn parse_lookup_advertisement(
5928    output: &OutputListItem,
5929    protocol: &[u8],
5930    scope: &str,
5931) -> Option<String> {
5932    let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
5933        .ok()?;
5934    let script = tx
5935        .outputs
5936        .get(output.output_index as usize)?
5937        .locking_script
5938        .to_binary();
5939    parse_advertisement_domain_from_script(&script, protocol, Some(scope))
5940}
5941
5942fn parse_advertisement_domain_from_script(
5943    output_script: &[u8],
5944    protocol: &[u8],
5945    scope: Option<&str>,
5946) -> Option<String> {
5947    use bsv_rs::script::templates::PushDrop;
5948
5949    let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
5950    let pushdrop = PushDrop::decode(&script.into()).ok()?;
5951
5952    if pushdrop.fields.len() < 3 {
5953        return None;
5954    }
5955
5956    if pushdrop.fields[0] != protocol {
5957        return None;
5958    }
5959
5960    if scope.is_some_and(|expected| {
5961        pushdrop
5962            .fields
5963            .get(3)
5964            .is_none_or(|actual| actual != expected.as_bytes())
5965    }) {
5966        return None;
5967    }
5968
5969    let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
5970    if domain.is_empty() {
5971        return None;
5972    }
5973
5974    Some(domain)
5975}
5976
5977// ============================================================================
5978// Submit-time spend verification helpers (reference parity, 2026-09-08)
5979// ============================================================================
5980
5981// ============================================================================
5982// Error type
5983// ============================================================================
5984
5985#[derive(Debug, thiserror::Error)]
5986pub enum EngineError {
5987    #[error("This server does not support this topic: {0}")]
5988    UnsupportedTopic(String),
5989
5990    #[error("Lookup service not found for provider: {0}")]
5991    LookupServiceNotFound(String),
5992
5993    /// GASP: `/requestForeignGASPNode` asked for a graph/txid we don't
5994    /// have locally. Matches mainline's 400-class "not found" response.
5995    #[error("No matching output found!")]
5996    NodeNotFound,
5997
5998    #[error("lookup failed: {0}")]
5999    LookupFailed(String),
6000
6001    /// The CALLER's query was malformed — a bad identity key, a gameId that is
6002    /// not 32 bytes of hex, a missing required field. Distinct from
6003    /// `LookupFailed`, which means WE failed: without the distinction every
6004    /// caller mistake was reported as a server fault (HTTP 500), so a malformed
6005    /// query and a genuine outage were indistinguishable in logs and alerts on
6006    /// an unauthenticated endpoint anyone can hit.
6007    #[error("invalid query: {0}")]
6008    InvalidQuery(String),
6009
6010    #[error("storage error: {0}")]
6011    StorageError(String),
6012
6013    #[error("broadcast error: {0}")]
6014    BroadcastError(String),
6015
6016    #[error("SPV verification failed: {0}")]
6017    SpvError(String),
6018
6019    #[error("BEEF parsing failed: {0}")]
6020    BeefParseError(String),
6021
6022    /// A spend in the submitted BEEF does not satisfy its source output's
6023    /// locking script: the input's unlocking script was EXECUTED (reference
6024    /// parity, 2026-09-08) and the interpreter refused it. Distinct from
6025    /// [`EngineError::SpvError`] (a bad or unverifiable PROOF, a missing
6026    /// source, a chain-tracker fault) so an operator can tell a bad spend from
6027    /// a bad proof. `input_index` is the failing input's position in the
6028    /// transaction whose script failed (the subject, or an unproven
6029    /// ancestor of it in the same BEEF); `reason` is the interpreter's own
6030    /// message.
6031    #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6032    ScriptVerificationFailed {
6033        subject_txid: String,
6034        input_index: u32,
6035        reason: String,
6036    },
6037
6038    /// The DOOR walk ([`Engine::verify_scripts_only`]) hit a STRUCTURAL fault
6039    /// before reaching a verdict on `at_txid` (a source the BEEF lacks, a
6040    /// parse fault, the value rule): not the interpreter's verdict, so not a
6041    /// refusal for a caller whose bar is the network. `subject_judged` says
6042    /// whether the subject's own inputs had all executed before the fault
6043    /// (an ancestor faulted) or not (the subject itself is unjudged).
6044    #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6045    ScriptWalkInconclusive {
6046        at_txid: String,
6047        subject_judged: bool,
6048        reason: String,
6049    },
6050
6051    /// The DOOR walk exceeded its own bound ([`DoorBudget`]: the static work
6052    /// estimate, the interpreter's memory limit tripping, or the estimate of
6053    /// the door's own memory; `limb` says which): the door's verdict, never
6054    /// the network's — the request proceeds to the network.
6055    #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6056    ScriptWalkOverBudget {
6057        at_txid: String,
6058        subject_judged: bool,
6059        limb: DoorLimb,
6060        what: String,
6061    },
6062
6063    /// The submit's own script walk COULD NOT RUN within the engine's
6064    /// [`DoorBudget`] (bsv-low #592) and nothing on record stands in for it
6065    /// ([`WalkBreachPolicy::NotNow`], every door but the queue replay of a
6066    /// gated submission; the E592 lens fold, H1). NOT NOW: nothing was
6067    /// judged, nothing written, and it is NOT a refusal either: the budget
6068    /// says nothing about the transaction. The way out is the subject
6069    /// PROVEN: a proven transaction is not walked, so an honest body too
6070    /// big to walk unmined lands once its block is mined (a client retries
6071    /// after the block, not at once).
6072    #[error(
6073        "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6074        .0.limb.as_str(),
6075        .0.what
6076    )]
6077    WalkCouldNotRun(WalkCouldNotRun),
6078
6079    #[error("{0}")]
6080    Other(String),
6081}
6082
6083impl From<StorageError> for EngineError {
6084    fn from(e: StorageError) -> Self {
6085        EngineError::StorageError(e.to_string())
6086    }
6087}
6088
6089impl From<TopicManagerError> for EngineError {
6090    fn from(e: TopicManagerError) -> Self {
6091        EngineError::Other(e.to_string())
6092    }
6093}
6094
6095impl From<LookupServiceError> for EngineError {
6096    fn from(e: LookupServiceError) -> Self {
6097        EngineError::LookupFailed(e.to_string())
6098    }
6099}
6100
6101impl From<AdvertiserError> for EngineError {
6102    fn from(e: AdvertiserError) -> Self {
6103        EngineError::Other(e.to_string())
6104    }
6105}
6106
6107// ============================================================================
6108// Tests
6109// ============================================================================
6110
6111/// Baseline sync order: configured peer topics before SHIP discovery topics,
6112/// each by name (bsv-low #555, D-M1). `start_gasp_sync` advances SHIP topics
6113/// with explicit bootstrap peers ahead of this order so their sync can run
6114/// without importing tracker records. Deferred-graph reservations still use
6115/// each topic's configured target.
6116pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6117    let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6118    order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6119    order
6120}
6121
6122#[cfg(test)]
6123mod tests {
6124    use super::*;
6125    use crate::lookup_service::LookupService as LookupServiceTrait;
6126    use crate::storage::memory::MemoryStorage;
6127    use crate::topic_manager::TopicManager as TopicManagerTrait;
6128    use async_trait::async_trait;
6129    use std::sync::Mutex;
6130
6131    // bsv-low #555, the delta fold's D-M1: the configured peers' topics are
6132    // synced before the ls_ship-discovered ones, each class by name; the map's
6133    // order was arbitrary.
6134    #[test]
6135    fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6136        let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6137        let config: SyncConfiguration = HashMap::from([
6138            ("tm_a".to_string(), SyncTarget::Ship),
6139            ("tm_z".to_string(), peers("https://z")),
6140            ("tm_b".to_string(), SyncTarget::Ship),
6141            ("tm_m".to_string(), peers("https://m")),
6142            ("tm_off".to_string(), SyncTarget::Disabled),
6143        ]);
6144        let order: Vec<&str> = sync_order(&config)
6145            .iter()
6146            .map(|(t, _)| t.as_str())
6147            .collect();
6148        assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6149    }
6150
6151    // ── Mock TopicManager ──────────────────────────────────────────────
6152
6153    struct MockTopicManager {
6154        admit_indices: Vec<u32>,
6155    }
6156
6157    impl MockTopicManager {
6158        fn admitting(indices: Vec<u32>) -> Self {
6159            Self {
6160                admit_indices: indices,
6161            }
6162        }
6163    }
6164
6165    #[async_trait(?Send)]
6166    impl TopicManagerTrait for MockTopicManager {
6167        async fn identify_admissible_outputs(
6168            &self,
6169            _tx: &Transaction,
6170            _previous_coins: &[u8],
6171            _off_chain_values: Option<&[u8]>,
6172            _mode: SubmitMode,
6173            _context: &TopicAdmittanceContext,
6174        ) -> Result<AdmittanceInstructions, TopicManagerError> {
6175            Ok(AdmittanceInstructions {
6176                outputs_to_admit: self.admit_indices.clone(),
6177                coins_to_retain: vec![],
6178                coins_removed: None,
6179            })
6180        }
6181
6182        async fn get_documentation(&self) -> String {
6183            "Mock topic manager".to_string()
6184        }
6185
6186        async fn get_metadata(&self) -> ServiceMetadata {
6187            ServiceMetadata {
6188                name: "mock-tm".to_string(),
6189                description: Some("Mock for testing".to_string()),
6190                ..Default::default()
6191            }
6192        }
6193    }
6194
6195    // ── Mock LookupService ─────────────────────────────────────────────
6196
6197    struct MockLookupService {
6198        records: Mutex<Vec<UTXOReference>>,
6199    }
6200
6201    impl MockLookupService {
6202        fn new() -> Self {
6203            Self {
6204                records: Mutex::new(Vec::new()),
6205            }
6206        }
6207    }
6208
6209    #[async_trait(?Send)]
6210    impl LookupServiceTrait for MockLookupService {
6211        fn admission_mode(&self) -> AdmissionMode {
6212            AdmissionMode::LockingScript
6213        }
6214
6215        fn spend_notification_mode(&self) -> SpendNotificationMode {
6216            SpendNotificationMode::None
6217        }
6218
6219        async fn output_admitted_by_topic(
6220            &self,
6221            payload: &OutputAdmittedByTopic,
6222        ) -> Result<(), LookupServiceError> {
6223            let (txid, oi) = match payload {
6224                OutputAdmittedByTopic::LockingScript {
6225                    txid, output_index, ..
6226                } => (txid.clone(), *output_index),
6227                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
6228                    ("whole".into(), *output_index)
6229                }
6230            };
6231            self.records.lock().unwrap().push(UTXOReference {
6232                txid,
6233                output_index: oi,
6234            });
6235            Ok(())
6236        }
6237
6238        async fn output_evicted(
6239            &self,
6240            txid: &str,
6241            output_index: u32,
6242        ) -> Result<(), LookupServiceError> {
6243            self.records
6244                .lock()
6245                .unwrap()
6246                .retain(|r| !(r.txid == txid && r.output_index == output_index));
6247            Ok(())
6248        }
6249
6250        async fn lookup(
6251            &self,
6252            _question: &LookupQuestion,
6253        ) -> Result<LookupResult, LookupServiceError> {
6254            Ok(LookupResult::OutputList(
6255                self.records.lock().unwrap().clone(),
6256            ))
6257        }
6258
6259        async fn get_documentation(&self) -> String {
6260            "Mock lookup service".to_string()
6261        }
6262
6263        async fn get_metadata(&self) -> ServiceMetadata {
6264            ServiceMetadata {
6265                name: "mock-ls".to_string(),
6266                ..Default::default()
6267            }
6268        }
6269    }
6270
6271    // ── Test BEEF data ──────────────────────────────────────────────────
6272
6273    /// Real BRC-62 BEEF from TS overlay-services test suite.
6274    const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
6275    const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
6276
6277    fn decode_hex(hex: &str) -> Vec<u8> {
6278        (0..hex.len())
6279            .step_by(2)
6280            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
6281            .collect()
6282    }
6283
6284    fn test_beef() -> Vec<u8> {
6285        decode_hex(TEST_BEEF_HEX)
6286    }
6287
6288    fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
6289        TaggedBEEF::new(
6290            test_beef(),
6291            topics.into_iter().map(str::to_string).collect(),
6292        )
6293    }
6294
6295    // ── Helpers ────────────────────────────────────────────────────────
6296
6297    fn make_engine(admit_indices: Vec<u32>) -> Engine {
6298        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6299        managers.insert(
6300            "tm_test".to_string(),
6301            Box::new(MockTopicManager::admitting(admit_indices)),
6302        );
6303
6304        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
6305        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
6306
6307        let storage = Box::new(MemoryStorage::new());
6308
6309        Engine::new(
6310            managers,
6311            lookup_services,
6312            storage,
6313            None,
6314            EngineConfig::default(),
6315        )
6316    }
6317
6318    // ── Tests ──────────────────────────────────────────────────────────
6319
6320    /// bsv-low#289 parse-once + zanaadu#284 subject discipline: the tx handed
6321    /// to every topic manager must be the ATOMIC SUBJECT of the submitted
6322    /// BEEF, not whatever sits last in wire order. bsv-rs < 0.3.20's
6323    /// `from_beef(_, None)` picked `txs.last()`; wallet serializers do not all
6324    /// place the subject last, so a TM could be handed a PARENT to validate.
6325    /// This pins the engine on the fixed rule (`txid ?? atomicTxid ?? last`).
6326    #[tokio::test]
6327    async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
6328        use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
6329        use std::cell::RefCell;
6330        use std::rc::Rc;
6331
6332        // Rebuild the fixture chain SUBJECT-FIRST via to_writer (to_binary
6333        // re-sorts parents-first and would hide the trigger).
6334        let parsed = Beef::from_binary(&test_beef()).unwrap();
6335        let parent = parsed.txs[0].tx().unwrap().clone();
6336        let child = parsed.txs[1].tx().unwrap().clone();
6337        let child_id = child.id();
6338        let parent_id = parent.id();
6339        let mut beef = Beef::new();
6340        // The parent's BUMP rides along: the reference-parity walk
6341        // (2026-09-08) executes the subject's script against the parent and
6342        // then trusts the parent on its proof; without the BUMP it would
6343        // demand the parent's own ancestry ("Input 0 has no source
6344        // transaction"), which this fixture does not carry.
6345        for bump in &parsed.bumps {
6346            beef.merge_bump(bump.clone());
6347        }
6348        beef.merge_transaction(child);
6349        beef.merge_transaction(parent);
6350        let mut w = bsv_rs::primitives::encoding::Writer::new();
6351        w.write_u32_le(ATOMIC_BEEF);
6352        let mut le = hex::decode(&child_id).unwrap();
6353        le.reverse();
6354        w.write_bytes(&le);
6355        beef.to_writer(&mut w);
6356        let atomic_wire_order = w.into_bytes();
6357
6358        // A TM that records the txid it is handed.
6359        struct RecordingTm(Rc<RefCell<Option<String>>>);
6360        #[async_trait(?Send)]
6361        impl TopicManagerTrait for RecordingTm {
6362            async fn identify_admissible_outputs(
6363                &self,
6364                tx: &Transaction,
6365                _previous_coins: &[u8],
6366                _off_chain_values: Option<&[u8]>,
6367                _mode: SubmitMode,
6368                _context: &TopicAdmittanceContext,
6369            ) -> Result<AdmittanceInstructions, TopicManagerError> {
6370                *self.0.borrow_mut() = Some(tx.id());
6371                Ok(AdmittanceInstructions {
6372                    outputs_to_admit: vec![0],
6373                    coins_to_retain: vec![],
6374                    coins_removed: None,
6375                })
6376            }
6377            async fn get_documentation(&self) -> String {
6378                String::new()
6379            }
6380            async fn get_metadata(&self) -> ServiceMetadata {
6381                ServiceMetadata {
6382                    name: "tm_test".to_string(),
6383                    ..Default::default()
6384                }
6385            }
6386        }
6387
6388        let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
6389        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6390        managers.insert(
6391            "tm_test".to_string(),
6392            Box::new(RecordingTm(Rc::clone(&seen))),
6393        );
6394        let engine = Engine::new(
6395            managers,
6396            HashMap::new(),
6397            Box::new(MemoryStorage::new()),
6398            None,
6399            EngineConfig::default(),
6400        );
6401
6402        let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
6403        let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
6404
6405        let handed = seen.borrow().clone().expect("TM was not invoked");
6406        assert_eq!(
6407            handed, child_id,
6408            "TM must be handed the atomic subject, got the wire-order pick"
6409        );
6410        assert_ne!(handed, parent_id);
6411    }
6412
6413    /// #4 REGRESSION: history hydration must never DOWNGRADE provenance.
6414    ///
6415    /// `get_utxo_history` used to `Transaction::from_beef(beef, None)` and then
6416    /// `tx.to_beef(true)`, which keeps only the subject and DROPS the stored
6417    /// BUMPs. Measured on prod before the fix: `ls_low` byGameId `73de6e48…`
6418    /// returned 1,172 B / 1 tx / 1 bump (PROVEN) with no header, and 676 B /
6419    /// 1 tx / 0 bumps with `x-history-depth: 3`. A caller asking for MORE
6420    /// provenance got a BEEF no wallet can verify — silently, behind a 200.
6421    ///
6422    /// There was NO test on this function at all, which is exactly how that
6423    /// shipped. This one asserts the invariant that matters: whatever else
6424    /// hydration does, a proof that went in comes back out.
6425    #[tokio::test]
6426    async fn history_hydration_preserves_the_stored_proof() {
6427        use bsv_rs::transaction::Beef;
6428
6429        let engine = make_engine(vec![0]);
6430        let stored = test_beef();
6431        let bumps_before = Beef::from_binary(&stored)
6432            .expect("fixture BEEF parses")
6433            .bumps
6434            .len();
6435        assert!(
6436            bumps_before > 0,
6437            "fixture must actually carry a proof, or this cell proves nothing"
6438        );
6439
6440        let output = Output {
6441            txid: TEST_TXID.to_string(),
6442            output_index: 0,
6443            output_script: vec![0x76, 0xa9],
6444            satoshis: 1000,
6445            topic: "tm_test".to_string(),
6446            spent: false,
6447            // No admitted parents: hydration has nothing to add, so anything
6448            // it REMOVES is pure loss — the cleanest form of the bug.
6449            outputs_consumed: vec![],
6450            consumed_by: vec![],
6451            beef: Some(stored.clone()),
6452            block_height: None,
6453            score: Some(1000.0),
6454        };
6455
6456        let hydrated = engine
6457            .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
6458            .await
6459            .expect("hydration must not error")
6460            .expect("hydration must return the output");
6461        let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
6462            .expect("hydrated BEEF parses");
6463
6464        assert_eq!(
6465            beef.bumps.len(),
6466            bumps_before,
6467            "asking for history must not strip the merkle proof (issue #4)"
6468        );
6469    }
6470
6471    #[tokio::test]
6472    async fn test_submit_unsupported_topic_errors() {
6473        let engine = make_engine(vec![0]);
6474        let beef = test_tagged_beef(vec!["tm_nonexistent"]);
6475
6476        let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
6477        assert!(result.is_err());
6478        assert!(matches!(
6479            result.unwrap_err(),
6480            EngineError::UnsupportedTopic(_)
6481        ));
6482    }
6483
6484    #[tokio::test]
6485    async fn test_submit_admits_outputs() {
6486        let engine = make_engine(vec![0, 1]);
6487        let beef = test_tagged_beef(vec!["tm_test"]);
6488
6489        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6490
6491        // STEAK should have tm_test with outputs 0 and 1 admitted
6492        let instructions = steak.get("tm_test").unwrap();
6493        assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
6494
6495        // Outputs should be in storage with real data
6496        let txid = TEST_TXID;
6497        let found = engine
6498            .storage()
6499            .find_output(txid, 0, Some("tm_test"), None, true)
6500            .await
6501            .unwrap()
6502            .expect("Output 0 should be in storage");
6503
6504        assert_eq!(found.txid, TEST_TXID);
6505        assert_eq!(found.satoshis, 26172); // Real value from BRC62 BEEF
6506        assert!(
6507            !found.output_script.is_empty(),
6508            "Script should be populated"
6509        );
6510        assert!(found.beef.is_some(), "BEEF should be stored");
6511        assert!(!found.spent);
6512    }
6513
6514    #[tokio::test]
6515    async fn test_submit_duplicate_is_skipped() {
6516        let engine = make_engine(vec![0]);
6517        let beef = test_tagged_beef(vec!["tm_test"]);
6518
6519        // First submit
6520        let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6521        assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
6522
6523        // Second submit — should be a dupe, no new outputs admitted
6524        let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6525        assert!(steak2["tm_test"].outputs_to_admit.is_empty());
6526    }
6527
6528    #[tokio::test]
6529    async fn test_submit_notifies_lookup_service() {
6530        let engine = make_engine(vec![0]);
6531        let beef = test_tagged_beef(vec!["tm_test"]);
6532
6533        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6534
6535        // Lookup service should have the output
6536        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6537        let answer = engine.lookup(&question, None).await.unwrap();
6538
6539        match answer {
6540            LookupAnswer::OutputList { outputs } => {
6541                assert_eq!(outputs.len(), 1);
6542            }
6543            _ => panic!("Expected OutputList"),
6544        }
6545    }
6546
6547    /// #289: `lookup_with_txids` returns the storage txid per hydrated
6548    /// output, aligned with the OutputList — the aggregated serializer
6549    /// writes it instead of re-hashing the BEEF.
6550    #[tokio::test]
6551    async fn test_lookup_with_txids_aligns_txids_with_outputs() {
6552        let engine = make_engine(vec![0]);
6553        let beef = test_tagged_beef(vec!["tm_test"]);
6554        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6555
6556        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6557        let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
6558        match answer {
6559            LookupAnswer::OutputList { outputs } => {
6560                assert_eq!(outputs.len(), 1);
6561                assert_eq!(txids.len(), outputs.len(), "one txid per output");
6562                assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
6563            }
6564            _ => panic!("Expected OutputList"),
6565        }
6566    }
6567
6568    /// #291: the public sync-response page size is always bounded.
6569    #[test]
6570    fn sync_limit_clamp() {
6571        assert_eq!(
6572            Engine::clamp_sync_limit(None),
6573            Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
6574            "absent limit gets the default page, never unbounded"
6575        );
6576        assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
6577        assert_eq!(
6578            Engine::clamp_sync_limit(Some(u64::MAX)),
6579            Engine::SYNC_RESPONSE_MAX_LIMIT,
6580            "a huge caller-supplied limit is capped"
6581        );
6582    }
6583
6584    #[tokio::test]
6585    async fn test_lookup_unknown_service_errors() {
6586        let engine = make_engine(vec![]);
6587        let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
6588
6589        let result = engine.lookup(&question, None).await;
6590        assert!(result.is_err());
6591        assert!(matches!(
6592            result.unwrap_err(),
6593            EngineError::LookupServiceNotFound(_)
6594        ));
6595    }
6596
6597    #[tokio::test]
6598    async fn test_list_topic_managers() {
6599        let engine = make_engine(vec![]);
6600        let managers = engine.list_topic_managers().await;
6601
6602        assert_eq!(managers.len(), 1);
6603        assert!(managers.contains_key("tm_test"));
6604        assert_eq!(managers["tm_test"].name, "mock-tm");
6605    }
6606
6607    #[tokio::test]
6608    async fn test_list_lookup_service_providers() {
6609        let engine = make_engine(vec![]);
6610        let services = engine.list_lookup_service_providers().await;
6611
6612        assert_eq!(services.len(), 1);
6613        assert!(services.contains_key("ls_test"));
6614        assert_eq!(services["ls_test"].name, "mock-ls");
6615    }
6616
6617    #[tokio::test]
6618    async fn test_get_documentation() {
6619        let engine = make_engine(vec![]);
6620
6621        let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
6622        assert_eq!(tm_docs, "Mock topic manager");
6623
6624        let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
6625        assert_eq!(ls_docs, "Mock lookup service");
6626
6627        let missing = engine
6628            .get_documentation_for_topic_manager("tm_missing")
6629            .await;
6630        assert_eq!(missing, "No documentation found!");
6631    }
6632
6633    #[tokio::test]
6634    async fn test_provide_foreign_sync_response() {
6635        let engine = make_engine(vec![0]);
6636
6637        // Submit data
6638        let beef = test_tagged_beef(vec!["tm_test"]);
6639        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6640
6641        // Query GASP sync
6642        let request = GASPInitialRequest {
6643            version: 1,
6644            since: 0,
6645            limit: Some(100),
6646        };
6647        let response = engine
6648            .provide_foreign_sync_response(&request, "tm_test")
6649            .await
6650            .unwrap();
6651
6652        // One tx admitted with output index 0
6653        assert_eq!(response.utxo_list.len(), 1);
6654        assert_eq!(response.utxo_list[0].txid, TEST_TXID);
6655        assert_eq!(response.since, 0);
6656    }
6657
6658    #[tokio::test]
6659    async fn test_delete_utxo_deep_simple() {
6660        let engine = make_engine(vec![0]);
6661        let beef = test_tagged_beef(vec!["tm_test"]);
6662
6663        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6664
6665        let txid = TEST_TXID;
6666        let output = engine
6667            .storage()
6668            .find_output(txid, 0, Some("tm_test"), None, false)
6669            .await
6670            .unwrap()
6671            .unwrap();
6672
6673        // Delete it
6674        engine.delete_utxo_deep(&output, None).await.unwrap();
6675
6676        // Should be gone
6677        let found = engine
6678            .storage()
6679            .find_output(txid, 0, Some("tm_test"), None, false)
6680            .await
6681            .unwrap();
6682        assert!(found.is_none());
6683    }
6684
6685    #[tokio::test]
6686    async fn test_sync_config_defaults_to_ship() {
6687        let engine = make_engine(vec![]);
6688        let sync = &engine.config.sync_configuration;
6689
6690        // tm_test should default to SHIP sync
6691        assert!(sync.contains_key("tm_test"));
6692        assert!(matches!(sync["tm_test"], SyncTarget::Ship));
6693    }
6694
6695    #[tokio::test]
6696    async fn test_multiple_topics_in_single_submit() {
6697        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6698        managers.insert(
6699            "tm_alpha".to_string(),
6700            Box::new(MockTopicManager::admitting(vec![0])),
6701        );
6702        managers.insert(
6703            "tm_beta".to_string(),
6704            Box::new(MockTopicManager::admitting(vec![1])),
6705        );
6706
6707        let engine = Engine::new(
6708            managers,
6709            HashMap::new(),
6710            Box::new(MemoryStorage::new()),
6711            None,
6712            EngineConfig::default(),
6713        );
6714
6715        let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
6716
6717        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6718
6719        assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
6720        assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
6721    }
6722
6723    #[tokio::test]
6724    async fn test_evict_output_with_topic() {
6725        let engine = make_engine(vec![0]);
6726        let beef = test_tagged_beef(vec!["tm_test"]);
6727        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6728
6729        // Output should exist
6730        let found = engine
6731            .storage()
6732            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
6733            .await
6734            .unwrap();
6735        assert!(found.is_some(), "Output should exist before eviction");
6736
6737        // Evict with topic
6738        engine
6739            .evict_output(TEST_TXID, 0, Some("tm_test"))
6740            .await
6741            .unwrap();
6742
6743        // Output should be gone from storage
6744        let found = engine
6745            .storage()
6746            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
6747            .await
6748            .unwrap();
6749        assert!(found.is_none(), "Output should be gone after eviction");
6750
6751        // Lookup service should also have been notified (output evicted)
6752        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6753        let answer = engine.lookup(&question, None).await.unwrap();
6754        match answer {
6755            LookupAnswer::OutputList { outputs } => {
6756                assert!(
6757                    outputs.is_empty(),
6758                    "Lookup should return empty after eviction"
6759                );
6760            }
6761            _ => panic!("Expected OutputList"),
6762        }
6763    }
6764
6765    #[tokio::test]
6766    async fn test_evict_output_without_topic() {
6767        let engine = make_engine(vec![0]);
6768        let beef = test_tagged_beef(vec!["tm_test"]);
6769        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6770
6771        // Evict without topic — should find and remove across all topics
6772        engine.evict_output(TEST_TXID, 0, None).await.unwrap();
6773
6774        let found = engine
6775            .storage()
6776            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
6777            .await
6778            .unwrap();
6779        assert!(
6780            found.is_none(),
6781            "Output should be gone after topic-less eviction"
6782        );
6783    }
6784
6785    #[tokio::test]
6786    async fn test_evict_nonexistent_output_is_ok() {
6787        let engine = make_engine(vec![]);
6788
6789        // Evicting something that doesn't exist should not error
6790        let result = engine
6791            .evict_output("nonexistent_txid", 99, Some("tm_test"))
6792            .await;
6793        assert!(result.is_ok(), "Evicting nonexistent output should succeed");
6794    }
6795
6796    // ── Mock Broadcaster ──────────────────────────────────────────────
6797
6798    use crate::broadcaster::Broadcaster;
6799    use std::sync::Arc;
6800
6801    type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
6802
6803    struct MockBroadcaster {
6804        calls: BroadcastCallLog,
6805    }
6806
6807    impl MockBroadcaster {
6808        fn new() -> Self {
6809            Self {
6810                calls: Arc::new(Mutex::new(Vec::new())),
6811            }
6812        }
6813
6814        #[allow(dead_code, reason = "kept for future test extension")]
6815        fn call_count(&self) -> usize {
6816            self.calls.lock().unwrap().len()
6817        }
6818
6819        #[allow(dead_code, reason = "kept for future test extension")]
6820        fn calls(&self) -> Vec<(String, Vec<String>)> {
6821            self.calls.lock().unwrap().clone()
6822        }
6823    }
6824
6825    #[async_trait(?Send)]
6826    impl Broadcaster for MockBroadcaster {
6827        async fn broadcast_to_host(
6828            &self,
6829            host_url: &str,
6830            tagged_beef: &TaggedBEEF,
6831        ) -> Result<(), String> {
6832            self.calls
6833                .lock()
6834                .unwrap()
6835                .push((host_url.to_string(), tagged_beef.topics.clone()));
6836            Ok(())
6837        }
6838    }
6839
6840    // ── Mock SHIP LookupService ─────────────────────────────────────
6841
6842    /// A mock "ls_ship" lookup service that returns pre-configured UTXOReferences
6843    /// when queried. Used to test SHIP propagation without depending on
6844    /// overlay-discovery in the engine crate's unit tests.
6845    struct MockSHIPLookupService {
6846        /// Maps topic -> list of (txid, output_index) references
6847        records: Mutex<HashMap<String, Vec<UTXOReference>>>,
6848    }
6849
6850    impl MockSHIPLookupService {
6851        fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
6852            Self {
6853                records: Mutex::new(records),
6854            }
6855        }
6856    }
6857
6858    #[async_trait(?Send)]
6859    impl LookupServiceTrait for MockSHIPLookupService {
6860        fn admission_mode(&self) -> AdmissionMode {
6861            AdmissionMode::LockingScript
6862        }
6863
6864        fn spend_notification_mode(&self) -> SpendNotificationMode {
6865            SpendNotificationMode::None
6866        }
6867
6868        async fn output_admitted_by_topic(
6869            &self,
6870            _payload: &OutputAdmittedByTopic,
6871        ) -> Result<(), LookupServiceError> {
6872            Ok(())
6873        }
6874
6875        async fn output_evicted(
6876            &self,
6877            _txid: &str,
6878            _output_index: u32,
6879        ) -> Result<(), LookupServiceError> {
6880            Ok(())
6881        }
6882
6883        async fn lookup(
6884            &self,
6885            question: &LookupQuestion,
6886        ) -> Result<LookupResult, LookupServiceError> {
6887            // Parse the topics from the query
6888            if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
6889                let records = self.records.lock().unwrap();
6890                let mut results = Vec::new();
6891                for topic_val in topics {
6892                    if let Some(topic) = topic_val.as_str() {
6893                        if let Some(refs) = records.get(topic) {
6894                            results.extend(refs.iter().cloned());
6895                        }
6896                    }
6897                }
6898                return Ok(LookupResult::OutputList(results));
6899            }
6900            Ok(LookupResult::OutputList(vec![]))
6901        }
6902
6903        async fn get_documentation(&self) -> String {
6904            "Mock SHIP lookup service".to_string()
6905        }
6906
6907        async fn get_metadata(&self) -> ServiceMetadata {
6908            ServiceMetadata {
6909                name: "mock-ls-ship".to_string(),
6910                ..Default::default()
6911            }
6912        }
6913    }
6914
6915    /// Build a minimal PushDrop locking script for a SHIP advertisement.
6916    fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
6917        use bsv_rs::script::templates::PushDrop;
6918        use bsv_rs::PublicKey;
6919
6920        let fields = vec![
6921            b"SHIP".to_vec(),
6922            vec![0x02; 33], // fake compressed pubkey bytes for identity_key field
6923            domain.as_bytes().to_vec(),
6924            topic.as_bytes().to_vec(),
6925        ];
6926
6927        // PushDrop requires a real compressed public key for the locking script
6928        // Use a well-known test key (generator point G)
6929        let pubkey = PublicKey::from_hex(
6930            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
6931        )
6932        .expect("valid test pubkey");
6933
6934        let pd = PushDrop::new(pubkey, fields);
6935        pd.lock().to_binary()
6936    }
6937
6938    /// Create engine with a broadcaster and a mock SHIP lookup service.
6939    /// The `ship_domains` parameter provides (domain, topic) pairs to pre-populate.
6940    async fn make_engine_with_broadcaster(
6941        admit_indices: Vec<u32>,
6942        broadcaster: MockBroadcaster,
6943        ship_domains: Vec<(&str, &str)>,
6944        hosting_url: Option<&str>,
6945    ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
6946        let calls = broadcaster.calls.clone();
6947
6948        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6949        managers.insert(
6950            "tm_test".to_string(),
6951            Box::new(MockTopicManager::admitting(admit_indices)),
6952        );
6953
6954        let storage = Box::new(MemoryStorage::new());
6955
6956        // Build SHIP records: map each topic to its UTXO references and
6957        // store outputs in storage with PushDrop scripts so the engine can
6958        // parse the domain when broadcasting.
6959        let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
6960
6961        for (domain, topic) in &ship_domains {
6962            // Deterministic fake txid per domain+topic
6963            let fake_txid = format!("{:064x}", {
6964                let mut h: u64 = 0;
6965                for b in domain.bytes().chain(topic.bytes()) {
6966                    h = h.wrapping_mul(31).wrapping_add(u64::from(b));
6967                }
6968                h
6969            });
6970
6971            // Add to SHIP records for the mock lookup service
6972            ship_records
6973                .entry(topic.to_string())
6974                .or_default()
6975                .push(UTXOReference {
6976                    txid: fake_txid.clone(),
6977                    output_index: 0,
6978                });
6979
6980            // Build PushDrop SHIP script and store as an output in main storage
6981            let script = build_ship_pushdrop_script(domain, topic);
6982
6983            let output = Output {
6984                txid: fake_txid,
6985                output_index: 0,
6986                output_script: script,
6987                satoshis: 1,
6988                topic: "tm_ship".to_string(),
6989                spent: false,
6990                outputs_consumed: vec![],
6991                consumed_by: vec![],
6992                beef: None,
6993                block_height: None,
6994                score: None,
6995            };
6996
6997            storage.insert_output(&output).await.unwrap();
6998        }
6999
7000        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7001        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7002        lookup_services.insert(
7003            "ls_ship".to_string(),
7004            Box::new(MockSHIPLookupService::with_records(ship_records)),
7005        );
7006
7007        let config = EngineConfig {
7008            hosting_url: hosting_url.map(str::to_string),
7009            ..Default::default()
7010        };
7011
7012        let engine = Engine::with_chain_tracker(
7013            managers,
7014            lookup_services,
7015            storage,
7016            None,
7017            Some(Box::new(broadcaster)),
7018            None,
7019            config,
7020        );
7021
7022        (engine, calls)
7023    }
7024
7025    // ── Broadcaster Tests ─────────────────────────────────────────────
7026
7027    #[tokio::test]
7028    async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
7029        let broadcaster = MockBroadcaster::new();
7030        let (engine, calls) = make_engine_with_broadcaster(
7031            vec![0],
7032            broadcaster,
7033            vec![("https://peer1.example.com", "tm_test")],
7034            Some("https://self.example.com"),
7035        )
7036        .await;
7037
7038        let beef = test_tagged_beef(vec!["tm_test"]);
7039        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7040
7041        // Submit should succeed
7042        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7043
7044        // Broadcaster should have been called for peer1
7045        let recorded = calls.lock().unwrap();
7046        assert_eq!(
7047            recorded.len(),
7048            1,
7049            "Broadcaster should be called once for the one SHIP peer"
7050        );
7051        assert_eq!(recorded[0].0, "https://peer1.example.com");
7052    }
7053
7054    #[tokio::test]
7055    async fn test_broadcaster_not_called_on_historical_tx() {
7056        let broadcaster = MockBroadcaster::new();
7057        let (engine, calls) = make_engine_with_broadcaster(
7058            vec![0],
7059            broadcaster,
7060            vec![("https://peer1.example.com", "tm_test")],
7061            None,
7062        )
7063        .await;
7064
7065        let beef = test_tagged_beef(vec!["tm_test"]);
7066        let steak = engine
7067            .submit(&beef, SubmitMode::HistoricalTx)
7068            .await
7069            .unwrap();
7070
7071        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7072
7073        // Broadcaster should NOT have been called
7074        assert_eq!(
7075            calls.lock().unwrap().len(),
7076            0,
7077            "Broadcaster should not be called for historical TX"
7078        );
7079    }
7080
7081    #[tokio::test]
7082    async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
7083        let broadcaster = MockBroadcaster::new();
7084        let (engine, calls) = make_engine_with_broadcaster(
7085            vec![0],
7086            broadcaster,
7087            vec![("https://peer1.example.com", "tm_test")],
7088            None,
7089        )
7090        .await;
7091
7092        let beef = test_tagged_beef(vec!["tm_test"]);
7093        let steak = engine
7094            .submit(&beef, SubmitMode::HistoricalTxNoSpv)
7095            .await
7096            .unwrap();
7097
7098        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7099
7100        assert_eq!(
7101            calls.lock().unwrap().len(),
7102            0,
7103            "Broadcaster should not be called for historical-tx-no-spv"
7104        );
7105    }
7106
7107    #[tokio::test]
7108    async fn test_broadcaster_skips_self_hosting_url() {
7109        let broadcaster = MockBroadcaster::new();
7110        let (engine, calls) = make_engine_with_broadcaster(
7111            vec![0],
7112            broadcaster,
7113            vec![
7114                ("https://self.example.com", "tm_test"),
7115                ("https://peer2.example.com", "tm_test"),
7116            ],
7117            Some("https://self.example.com"),
7118        )
7119        .await;
7120
7121        let beef = test_tagged_beef(vec!["tm_test"]);
7122        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7123
7124        // Should only broadcast to peer2, not to self
7125        let recorded = calls.lock().unwrap();
7126        assert_eq!(
7127            recorded.len(),
7128            1,
7129            "Should broadcast to peer2 only, skipping self"
7130        );
7131        assert_eq!(recorded[0].0, "https://peer2.example.com");
7132    }
7133
7134    #[tokio::test]
7135    async fn test_broadcaster_not_called_when_no_outputs_admitted() {
7136        let broadcaster = MockBroadcaster::new();
7137        // Topic manager admits nothing
7138        let (engine, calls) = make_engine_with_broadcaster(
7139            vec![],
7140            broadcaster,
7141            vec![("https://peer1.example.com", "tm_test")],
7142            None,
7143        )
7144        .await;
7145
7146        let beef = test_tagged_beef(vec!["tm_test"]);
7147        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7148
7149        assert!(steak["tm_test"].outputs_to_admit.is_empty());
7150
7151        // No outputs admitted -> no broadcast
7152        assert_eq!(
7153            calls.lock().unwrap().len(),
7154            0,
7155            "Broadcaster should not be called when no outputs are admitted"
7156        );
7157    }
7158
7159    #[tokio::test]
7160    async fn test_broadcaster_not_called_when_no_ship_peers() {
7161        let broadcaster = MockBroadcaster::new();
7162        // No SHIP peers registered
7163        let (engine, calls) = make_engine_with_broadcaster(
7164            vec![0],
7165            broadcaster,
7166            vec![], // no SHIP peers
7167            None,
7168        )
7169        .await;
7170
7171        let beef = test_tagged_beef(vec!["tm_test"]);
7172        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7173
7174        assert_eq!(
7175            calls.lock().unwrap().len(),
7176            0,
7177            "Broadcaster should not be called when no SHIP peers exist"
7178        );
7179    }
7180
7181    // ── Configurable SpendNotification mock ─────────────────────────────
7182
7183    /// A mock lookup service whose SpendNotificationMode can be configured.
7184    /// Uses a shared Arc<Mutex<Vec<OutputSpent>>> so the caller can inspect
7185    /// captured payloads after submit().
7186    struct SpendModeLookupService {
7187        mode: SpendNotificationMode,
7188        records: Mutex<Vec<UTXOReference>>,
7189        spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
7190    }
7191
7192    impl SpendModeLookupService {
7193        fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
7194            Self {
7195                mode,
7196                records: Mutex::new(Vec::new()),
7197                spent_payloads: capture,
7198            }
7199        }
7200    }
7201
7202    #[async_trait(?Send)]
7203    impl LookupServiceTrait for SpendModeLookupService {
7204        fn admission_mode(&self) -> AdmissionMode {
7205            AdmissionMode::LockingScript
7206        }
7207
7208        fn spend_notification_mode(&self) -> SpendNotificationMode {
7209            self.mode
7210        }
7211
7212        async fn output_admitted_by_topic(
7213            &self,
7214            payload: &OutputAdmittedByTopic,
7215        ) -> Result<(), LookupServiceError> {
7216            let (txid, oi) = match payload {
7217                OutputAdmittedByTopic::LockingScript {
7218                    txid, output_index, ..
7219                } => (txid.clone(), *output_index),
7220                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
7221                    ("whole".into(), *output_index)
7222                }
7223            };
7224            self.records.lock().unwrap().push(UTXOReference {
7225                txid,
7226                output_index: oi,
7227            });
7228            Ok(())
7229        }
7230
7231        async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
7232            self.spent_payloads.lock().unwrap().push(payload.clone());
7233            Ok(())
7234        }
7235
7236        async fn output_evicted(
7237            &self,
7238            txid: &str,
7239            output_index: u32,
7240        ) -> Result<(), LookupServiceError> {
7241            self.records
7242                .lock()
7243                .unwrap()
7244                .retain(|r| !(r.txid == txid && r.output_index == output_index));
7245            Ok(())
7246        }
7247
7248        async fn lookup(
7249            &self,
7250            _question: &LookupQuestion,
7251        ) -> Result<LookupResult, LookupServiceError> {
7252            Ok(LookupResult::OutputList(
7253                self.records.lock().unwrap().clone(),
7254            ))
7255        }
7256
7257        async fn get_documentation(&self) -> String {
7258            "SpendMode mock lookup service".to_string()
7259        }
7260
7261        async fn get_metadata(&self) -> ServiceMetadata {
7262            ServiceMetadata {
7263                name: "spend-mode-ls".to_string(),
7264                ..Default::default()
7265            }
7266        }
7267    }
7268
7269    /// TXID of the input's source transaction in TEST_BEEF_HEX (the "previous coin").
7270    const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
7271
7272    /// Build an engine with a pre-populated previous output and a SpendModeLookupService.
7273    /// Returns the engine and the shared capture vec for inspecting OutputSpent payloads.
7274    async fn make_spend_mode_engine(
7275        mode: SpendNotificationMode,
7276    ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
7277        let storage = MemoryStorage::new();
7278
7279        // Pre-populate storage with the previous output that TEST_BEEF_HEX's input spends.
7280        let prev_output = Output {
7281            txid: PREVIOUS_TXID.to_string(),
7282            output_index: 0,
7283            output_script: vec![0x76, 0xa9],
7284            satoshis: 26174,
7285            topic: "tm_test".to_string(),
7286            spent: false,
7287            outputs_consumed: vec![],
7288            consumed_by: vec![],
7289            beef: Some(test_beef()),
7290            block_height: None,
7291            score: Some(1000.0),
7292        };
7293        storage.insert_output(&prev_output).await.unwrap();
7294
7295        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7296        managers.insert(
7297            "tm_test".to_string(),
7298            Box::new(MockTopicManager::admitting(vec![0])),
7299        );
7300
7301        let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
7302        let ls = SpendModeLookupService::with_mode(mode, capture.clone());
7303        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7304        lookup_services.insert("ls_test".to_string(), Box::new(ls));
7305
7306        let engine = Engine::new(
7307            managers,
7308            lookup_services,
7309            Box::new(storage),
7310            None,
7311            EngineConfig::default(),
7312        );
7313
7314        (engine, capture)
7315    }
7316
7317    #[tokio::test]
7318    async fn test_spend_notification_mode_script() {
7319        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
7320
7321        let beef = test_tagged_beef(vec!["tm_test"]);
7322        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7323
7324        // The new output should be admitted
7325        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7326
7327        // The lookup service should have received a Script spend notification
7328        let payloads = capture.lock().unwrap().clone();
7329        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7330
7331        match &payloads[0] {
7332            OutputSpent::Script {
7333                txid,
7334                output_index,
7335                topic,
7336                spending_txid,
7337                input_index,
7338                unlocking_script,
7339                sequence_number,
7340                ..
7341            } => {
7342                assert_eq!(txid, PREVIOUS_TXID);
7343                assert_eq!(*output_index, 0);
7344                assert_eq!(topic, "tm_test");
7345                assert_eq!(spending_txid, TEST_TXID);
7346                // The BEEF has a single input at index 0 that spends PREVIOUS_TXID:0
7347                assert_eq!(*input_index, 0);
7348                // The unlocking script should be non-empty (it's a P2PKH scriptSig)
7349                assert!(
7350                    !unlocking_script.is_empty(),
7351                    "Unlocking script should be non-empty"
7352                );
7353                // Standard final sequence number
7354                assert_eq!(*sequence_number, 0xffff_ffff);
7355            }
7356            other => panic!("Expected OutputSpent::Script, got: {other:?}"),
7357        }
7358    }
7359
7360    #[tokio::test]
7361    async fn test_spend_notification_mode_whole_tx() {
7362        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
7363
7364        let beef = test_tagged_beef(vec!["tm_test"]);
7365        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7366
7367        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7368
7369        let payloads = capture.lock().unwrap().clone();
7370        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7371
7372        match &payloads[0] {
7373            OutputSpent::WholeTx {
7374                txid,
7375                output_index,
7376                topic,
7377                spending_atomic_beef,
7378                ..
7379            } => {
7380                assert_eq!(txid, PREVIOUS_TXID);
7381                assert_eq!(*output_index, 0);
7382                assert_eq!(topic, "tm_test");
7383                // The spending BEEF is the entire BEEF we submitted, NAMED
7384                // (atomic prefix) for the lookup service's own re-parse.
7385                let mut named =
7386                    bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
7387                assert!(named.is_atomic());
7388                let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
7389                assert_eq!(
7390                    named.txs.len(),
7391                    submitted.txs.len(),
7392                    "the whole body, never pruned"
7393                );
7394                let tip = crate::subject::subject_txid_of(&mut named).unwrap();
7395                assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
7396            }
7397            other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
7398        }
7399    }
7400
7401    #[tokio::test]
7402    async fn test_spend_notification_mode_txid() {
7403        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
7404
7405        let beef = test_tagged_beef(vec!["tm_test"]);
7406        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7407
7408        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7409
7410        let payloads = capture.lock().unwrap().clone();
7411        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7412
7413        match &payloads[0] {
7414            OutputSpent::Txid {
7415                txid,
7416                output_index,
7417                topic,
7418                spending_txid,
7419            } => {
7420                assert_eq!(txid, PREVIOUS_TXID);
7421                assert_eq!(*output_index, 0);
7422                assert_eq!(topic, "tm_test");
7423                assert_eq!(spending_txid, TEST_TXID);
7424            }
7425            other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
7426        }
7427    }
7428
7429    #[tokio::test]
7430    async fn test_spend_notification_mode_none() {
7431        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
7432
7433        let beef = test_tagged_beef(vec!["tm_test"]);
7434        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7435
7436        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7437
7438        let payloads = capture.lock().unwrap().clone();
7439        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7440
7441        match &payloads[0] {
7442            OutputSpent::None {
7443                txid,
7444                output_index,
7445                topic,
7446            } => {
7447                assert_eq!(txid, PREVIOUS_TXID);
7448                assert_eq!(*output_index, 0);
7449                assert_eq!(topic, "tm_test");
7450            }
7451            other => panic!("Expected OutputSpent::None, got: {other:?}"),
7452        }
7453    }
7454
7455    // ── GASP Sync Tests ───────────────────────────────────────────────
7456
7457    #[tokio::test]
7458    async fn test_start_gasp_sync_empty_config() {
7459        let engine = Engine::new(
7460            HashMap::new(),
7461            HashMap::new(),
7462            Box::new(MemoryStorage::new()),
7463            None,
7464            EngineConfig {
7465                sync_configuration: HashMap::new(),
7466                ..Default::default()
7467            },
7468        );
7469
7470        let result = engine.start_gasp_sync().await.unwrap();
7471        assert!(
7472            result.topics_synced.is_empty(),
7473            "No topics should be synced with empty config"
7474        );
7475    }
7476
7477    #[tokio::test]
7478    async fn test_start_gasp_sync_disabled_topic_skipped() {
7479        let mut sync_config: SyncConfiguration = HashMap::new();
7480        sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
7481
7482        let engine = Engine::new(
7483            HashMap::new(),
7484            HashMap::new(),
7485            Box::new(MemoryStorage::new()),
7486            None,
7487            EngineConfig {
7488                sync_configuration: sync_config,
7489                ..Default::default()
7490            },
7491        );
7492
7493        let result = engine.start_gasp_sync().await.unwrap();
7494        assert!(
7495            result.topics_synced.is_empty(),
7496            "Disabled topics should not appear in results"
7497        );
7498    }
7499
7500    #[tokio::test]
7501    async fn test_start_gasp_sync_peers_config() {
7502        let mut sync_config: SyncConfiguration = HashMap::new();
7503        sync_config.insert(
7504            "tm_test".to_string(),
7505            SyncTarget::Peers(vec![
7506                "https://peer1.example.com".to_string(),
7507                "https://peer2.example.com".to_string(),
7508            ]),
7509        );
7510
7511        let engine = Engine::new(
7512            HashMap::new(),
7513            HashMap::new(),
7514            Box::new(MemoryStorage::new()),
7515            None,
7516            EngineConfig {
7517                sync_configuration: sync_config,
7518                ..Default::default()
7519            },
7520        );
7521
7522        let result = engine.start_gasp_sync().await.unwrap();
7523        assert_eq!(result.topics_synced.len(), 1);
7524
7525        let topic_result = &result.topics_synced["tm_test"];
7526        assert_eq!(topic_result.peers.len(), 2);
7527        assert_eq!(topic_result.sync_type, "peers");
7528        assert!(topic_result.errors.is_empty());
7529    }
7530
7531    #[tokio::test]
7532    async fn test_start_gasp_sync_peers_filters_self() {
7533        let mut sync_config: SyncConfiguration = HashMap::new();
7534        sync_config.insert(
7535            "tm_test".to_string(),
7536            SyncTarget::Peers(vec![
7537                "https://self.example.com".to_string(),
7538                "https://peer2.example.com".to_string(),
7539            ]),
7540        );
7541
7542        let engine = Engine::new(
7543            HashMap::new(),
7544            HashMap::new(),
7545            Box::new(MemoryStorage::new()),
7546            None,
7547            EngineConfig {
7548                hosting_url: Some("https://self.example.com".to_string()),
7549                sync_configuration: sync_config,
7550                ..Default::default()
7551            },
7552        );
7553
7554        let result = engine.start_gasp_sync().await.unwrap();
7555        let topic_result = &result.topics_synced["tm_test"];
7556        assert_eq!(
7557            topic_result.peers.len(),
7558            1,
7559            "Self URL should be filtered out"
7560        );
7561        assert_eq!(topic_result.peers[0], "https://peer2.example.com");
7562    }
7563
7564    #[derive(Clone, Default)]
7565    struct MockShipLookupClient {
7566        records: HashMap<(String, String), Vec<OutputListItem>>,
7567        failures: HashSet<String>,
7568        calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
7569    }
7570
7571    struct RecordingDiscoverySyncFactory {
7572        calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
7573    }
7574
7575    impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
7576        fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
7577            self.calls
7578                .borrow_mut()
7579                .push((peer.to_string(), topic.to_string()));
7580            Box::new(MockSyncRemote { utxos: Vec::new() })
7581        }
7582    }
7583
7584    #[async_trait(?Send)]
7585    impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
7586        async fn lookup(
7587            &self,
7588            host: &str,
7589            question: &LookupQuestion,
7590        ) -> Result<Vec<OutputListItem>, String> {
7591            self.calls
7592                .borrow_mut()
7593                .push((host.to_string(), question.clone()));
7594            if self.failures.contains(host) {
7595                return Err("mock tracker unavailable".to_string());
7596            }
7597            Ok(self
7598                .records
7599                .get(&(host.to_string(), question.service.clone()))
7600                .cloned()
7601                .unwrap_or_default())
7602        }
7603    }
7604
7605    fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
7606        use bsv_rs::script::templates::PushDrop;
7607        let pubkey = bsv_rs::PublicKey::from_hex(
7608            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7609        )
7610        .unwrap();
7611        let script = PushDrop::new(
7612            pubkey,
7613            vec![
7614                protocol.as_bytes().to_vec(),
7615                vec![0x02; 33],
7616                domain.as_bytes().to_vec(),
7617                scope.as_bytes().to_vec(),
7618            ],
7619        )
7620        .lock();
7621        let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
7622        tx.outputs[0].locking_script = script;
7623        tx.merkle_path = None;
7624        tx.invalidate_caches();
7625        let beef = tx.to_atomic_beef(true).unwrap();
7626        let parsed =
7627            beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
7628                .unwrap();
7629        assert_eq!(
7630            parsed.outputs[0].locking_script,
7631            tx.outputs[0].locking_script
7632        );
7633        OutputListItem {
7634            beef,
7635            output_index: 0,
7636            context: None,
7637        }
7638    }
7639
7640    #[tokio::test]
7641    async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
7642        let (mut engine, _) = make_engine_with_broadcaster(
7643            vec![0],
7644            MockBroadcaster::new(),
7645            vec![
7646                ("https://local.example", "tm_test"),
7647                ("https://shared.example/?local", "tm_test"),
7648            ],
7649            Some("https://self.example"),
7650        )
7651        .await;
7652        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
7653        engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
7654        let mut client = MockShipLookupClient::default();
7655        client.records.insert(
7656            ("https://tracker.example".to_string(), "ls_ship".to_string()),
7657            vec![
7658                discovery_output("SHIP", "https://direct.example", "tm_test"),
7659                discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
7660                discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
7661                discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
7662                discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
7663                OutputListItem {
7664                    beef: vec![1, 2, 3],
7665                    output_index: 0,
7666                    context: None,
7667                },
7668            ],
7669        );
7670        client.records.insert(
7671            ("https://tracker.example".to_string(), "ls_slap".to_string()),
7672            vec![
7673                discovery_output("SLAP", "https://lookup.example", "ls_ship"),
7674                discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
7675                discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
7676                discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
7677                discovery_output("SLAP", "https://other-service.example", "ls_other"),
7678            ],
7679        );
7680        client.records.insert(
7681            ("https://lookup.example".to_string(), "ls_ship".to_string()),
7682            vec![
7683                discovery_output("SHIP", "https://indirect.example", "tm_test"),
7684                discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
7685            ],
7686        );
7687        let calls = client.calls.clone();
7688        engine.set_ship_lookup_client(Box::new(client));
7689        let result = engine.start_gasp_sync().await.unwrap();
7690        assert_eq!(
7691            result.topics_synced["tm_test"].peers,
7692            vec![
7693                "https://bootstrap.example",
7694                "https://direct.example",
7695                "https://indirect.example",
7696                "https://local.example",
7697                "https://shared.example",
7698            ]
7699        );
7700        let calls = calls.borrow();
7701        assert_eq!(calls.len(), 3);
7702        for (_, question) in calls.iter() {
7703            let expected = if question.service == "ls_slap" {
7704                serde_json::json!({"service": "ls_ship"})
7705            } else {
7706                serde_json::json!({"topics": ["tm_test"]})
7707            };
7708            assert_eq!(question.query, expected);
7709        }
7710    }
7711
7712    #[tokio::test]
7713    async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
7714        let (mut engine, _) =
7715            make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
7716        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
7717        let mut client = MockShipLookupClient::default();
7718        let outputs = (0..9)
7719            .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
7720            .collect();
7721        client.records.insert(
7722            ("https://tracker.example".to_string(), "ls_ship".to_string()),
7723            outputs,
7724        );
7725        engine.set_ship_lookup_client(Box::new(client));
7726        let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
7727        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
7728            calls: sync_calls.clone(),
7729        }));
7730        let result = engine.start_gasp_sync().await.unwrap();
7731        let topic = &result.topics_synced["tm_test"];
7732        assert_eq!(topic.sync_type, "ship");
7733        assert_eq!(
7734            topic.peers.len(),
7735            9,
7736            "the lookup-host cap must not truncate sync peers"
7737        );
7738        assert!(topic.errors.is_empty());
7739        assert_eq!(sync_calls.borrow().len(), 9);
7740        for peer in &topic.peers {
7741            assert!(sync_calls
7742                .borrow()
7743                .contains(&(peer.clone(), "tm_test".to_string())));
7744        }
7745    }
7746
7747    #[tokio::test]
7748    async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
7749        let mut engine = make_engine(vec![]);
7750        engine.config.slap_trackers = vec![
7751            "https://dead.example".to_string(),
7752            "https://live.example".to_string(),
7753        ];
7754        let mut client = MockShipLookupClient::default();
7755        client.failures.insert("https://dead.example".to_string());
7756        client.records.insert(
7757            ("https://live.example".to_string(), "ls_ship".to_string()),
7758            vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
7759        );
7760        engine.set_ship_lookup_client(Box::new(client));
7761        let result = engine.start_gasp_sync().await.unwrap();
7762        assert_eq!(
7763            result.topics_synced["tm_test"].peers,
7764            vec!["https://peer.example"]
7765        );
7766    }
7767
7768    #[tokio::test]
7769    async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
7770        use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
7771        let mut engine = make_engine(vec![]);
7772        engine
7773            .config
7774            .sync_configuration
7775            .insert("tm_other".to_string(), SyncTarget::Ship);
7776        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
7777        let mut client = MockShipLookupClient::default();
7778        let mut providers = Vec::new();
7779        for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
7780            let host = format!("https://lookup{n}.example");
7781            providers.push(discovery_output("SLAP", &host, "ls_ship"));
7782            client.records.insert(
7783                (host, "ls_ship".to_string()),
7784                vec![
7785                    discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
7786                    discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
7787                ],
7788            );
7789        }
7790        client.records.insert(
7791            ("https://tracker.example".to_string(), "ls_slap".to_string()),
7792            providers,
7793        );
7794        let calls = client.calls.clone();
7795        engine.set_ship_lookup_client(Box::new(client));
7796        let result = engine.start_gasp_sync().await.unwrap();
7797        assert_eq!(
7798            result.topics_synced["tm_test"].peers.len(),
7799            SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
7800        );
7801        assert_eq!(
7802            result.topics_synced["tm_other"].peers.len(),
7803            SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
7804        );
7805        let calls = calls.borrow();
7806        assert_eq!(
7807            calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
7808            1
7809        );
7810        let providers: HashSet<_> = calls
7811            .iter()
7812            .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
7813            .map(|(h, _)| h)
7814            .collect();
7815        assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
7816        assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
7817    }
7818
7819    #[tokio::test]
7820    async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
7821        let mut engine = make_engine(vec![]);
7822        engine.config.hosting_url = Some("https://self.example".to_string());
7823        engine.config.slap_trackers = vec!["https://dead.example".to_string()];
7824        let mut client = MockShipLookupClient::default();
7825        client.failures.insert("https://dead.example".to_string());
7826        engine.set_ship_lookup_client(Box::new(client));
7827        engine.set_ship_bootstrap_peers(
7828            "tm_test",
7829            vec![
7830                "https://bootstrap.example".to_string(),
7831                "https://BOOTSTRAP.example.:443/?x".to_string(),
7832                "http://SELF.example.:8443/path".to_string(),
7833            ],
7834        );
7835        let result = engine.start_gasp_sync().await.unwrap();
7836        assert_eq!(
7837            result.topics_synced["tm_test"].peers,
7838            vec!["https://bootstrap.example"]
7839        );
7840    }
7841
7842    #[tokio::test]
7843    async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
7844        let mut engine = make_engine(vec![]);
7845        for topic in ["tm_ship", "tm_slap"] {
7846            engine.config.sync_configuration.insert(
7847                topic.to_string(),
7848                SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
7849            );
7850        }
7851        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
7852        let mut client = MockShipLookupClient::default();
7853        client.records.insert(
7854            ("https://tracker.example".to_string(), "ls_ship".to_string()),
7855            vec![discovery_output(
7856                "SHIP",
7857                "https://discovered.example",
7858                "tm_test",
7859            )],
7860        );
7861        let lookups = client.calls.clone();
7862        engine.set_ship_lookup_client(Box::new(client));
7863        engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
7864        let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
7865        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
7866            calls: calls.clone(),
7867        }));
7868        engine.start_gasp_sync().await.unwrap();
7869        assert_eq!(
7870            calls
7871                .borrow()
7872                .iter()
7873                .map(|(_, topic)| topic.as_str())
7874                .collect::<Vec<_>>(),
7875            vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
7876        );
7877        assert_eq!(lookups.borrow().len(), 2);
7878    }
7879
7880    #[tokio::test]
7881    async fn test_start_gasp_sync_ship_discovers_peers() {
7882        let broadcaster = MockBroadcaster::new();
7883        let (engine, _calls) = make_engine_with_broadcaster(
7884            vec![0],
7885            broadcaster,
7886            vec![
7887                ("https://peer1.example.com", "tm_test"),
7888                ("https://peer2.example.com", "tm_test"),
7889            ],
7890            Some("https://self.example.com"),
7891        )
7892        .await;
7893
7894        let result = engine.start_gasp_sync().await.unwrap();
7895        assert!(
7896            result.topics_synced.contains_key("tm_test"),
7897            "tm_test should be in sync results"
7898        );
7899
7900        let topic_result = &result.topics_synced["tm_test"];
7901        assert_eq!(topic_result.sync_type, "ship");
7902        assert_eq!(
7903            topic_result.peers.len(),
7904            2,
7905            "Should discover both SHIP peers"
7906        );
7907        assert!(topic_result
7908            .peers
7909            .contains(&"https://peer1.example.com".to_string()));
7910        assert!(topic_result
7911            .peers
7912            .contains(&"https://peer2.example.com".to_string()));
7913    }
7914
7915    #[tokio::test]
7916    async fn test_start_gasp_sync_ship_filters_self() {
7917        let broadcaster = MockBroadcaster::new();
7918        let (engine, _calls) = make_engine_with_broadcaster(
7919            vec![0],
7920            broadcaster,
7921            vec![
7922                ("https://self.example.com", "tm_test"),
7923                ("https://peer2.example.com", "tm_test"),
7924            ],
7925            Some("https://self.example.com"),
7926        )
7927        .await;
7928
7929        let result = engine.start_gasp_sync().await.unwrap();
7930        let topic_result = &result.topics_synced["tm_test"];
7931        assert_eq!(
7932            topic_result.peers.len(),
7933            1,
7934            "Self URL should be filtered out from SHIP discovery"
7935        );
7936        assert_eq!(topic_result.peers[0], "https://peer2.example.com");
7937    }
7938
7939    #[tokio::test]
7940    async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
7941        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7942        managers.insert(
7943            "tm_custom".to_string(),
7944            Box::new(MockTopicManager::admitting(vec![])),
7945        );
7946
7947        let mut sync_config: SyncConfiguration = HashMap::new();
7948        sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
7949
7950        let engine = Engine::new(
7951            managers,
7952            HashMap::new(),
7953            Box::new(MemoryStorage::new()),
7954            None,
7955            EngineConfig {
7956                sync_configuration: sync_config,
7957                ..Default::default()
7958            },
7959        );
7960
7961        let result = engine.start_gasp_sync().await.unwrap();
7962        let topic_result = &result.topics_synced["tm_custom"];
7963        assert!(
7964            topic_result.peers.is_empty(),
7965            "Without ls_ship, SHIP discovery should return no peers"
7966        );
7967    }
7968
7969    #[tokio::test]
7970    async fn test_start_gasp_sync_mixed_config() {
7971        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7972        managers.insert(
7973            "tm_peers".to_string(),
7974            Box::new(MockTopicManager::admitting(vec![])),
7975        );
7976        managers.insert(
7977            "tm_disabled".to_string(),
7978            Box::new(MockTopicManager::admitting(vec![])),
7979        );
7980        managers.insert(
7981            "tm_ship_topic".to_string(),
7982            Box::new(MockTopicManager::admitting(vec![])),
7983        );
7984
7985        let mut sync_config: SyncConfiguration = HashMap::new();
7986        sync_config.insert(
7987            "tm_peers".to_string(),
7988            SyncTarget::Peers(vec!["https://peer.com".to_string()]),
7989        );
7990        sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
7991        sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
7992
7993        let engine = Engine::new(
7994            managers,
7995            HashMap::new(),
7996            Box::new(MemoryStorage::new()),
7997            None,
7998            EngineConfig {
7999                sync_configuration: sync_config,
8000                ..Default::default()
8001            },
8002        );
8003
8004        let result = engine.start_gasp_sync().await.unwrap();
8005        assert_eq!(result.topics_synced.len(), 2);
8006        assert!(result.topics_synced.contains_key("tm_peers"));
8007        assert!(result.topics_synced.contains_key("tm_ship_topic"));
8008        assert!(!result.topics_synced.contains_key("tm_disabled"));
8009
8010        assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
8011        assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
8012        assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
8013    }
8014
8015    #[tokio::test]
8016    async fn test_gasp_sync_result_serialization() {
8017        let mut topics_synced = HashMap::new();
8018        topics_synced.insert(
8019            "tm_test".to_string(),
8020            super::TopicSyncResult {
8021                peers: vec!["https://peer.com".to_string()],
8022                sync_type: "ship".to_string(),
8023                errors: vec![],
8024                pruned_inputs: 0,
8025                discarded_graphs: 0,
8026                anchor_walk_held_graphs: 0,
8027                finalized_graphs: 0,
8028                deadline_dropped_graphs: 0,
8029                cursor_moves: Vec::new(),
8030                deferred_graphs: 0,
8031                resumed_graphs: 0,
8032                converged_graphs: 0,
8033                dropped_graphs: Vec::new(),
8034                stalled_graphs: 0,
8035                held_back_graphs: 0,
8036            },
8037        );
8038
8039        let result = super::GASPSyncResult { topics_synced };
8040        let json = serde_json::to_string(&result).unwrap();
8041        let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
8042
8043        assert_eq!(back.topics_synced.len(), 1);
8044        assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
8045        assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
8046    }
8047
8048    // ── GASP Sync With Factory Tests ─────────────────────────────────
8049
8050    /// Mock GASPRemote for factory tests
8051    struct MockSyncRemote {
8052        utxos: Vec<crate::types::GASPOutput>,
8053    }
8054
8055    #[async_trait(?Send)]
8056    impl crate::gasp::GASPRemote for MockSyncRemote {
8057        async fn get_initial_response(
8058            &self,
8059            request: &crate::types::GASPInitialRequest,
8060        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
8061            let utxos: Vec<crate::types::GASPOutput> = self
8062                .utxos
8063                .iter()
8064                .filter(|u| u.score as u64 >= request.since)
8065                .cloned()
8066                .collect();
8067            Ok(crate::types::GASPInitialResponse {
8068                utxo_list: utxos,
8069                since: request.since,
8070            })
8071        }
8072        async fn get_initial_reply(
8073            &self,
8074            _: &crate::types::GASPInitialResponse,
8075        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
8076            Ok(crate::types::GASPInitialReply {
8077                utxo_list: Vec::new(),
8078            })
8079        }
8080        async fn request_node(
8081            &self,
8082            graph_id: &str,
8083            txid: &str,
8084            output_index: u32,
8085            _: bool,
8086        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
8087            Ok(crate::types::GASPNode {
8088                graph_id: graph_id.to_string(),
8089                raw_tx: format!("rawtx_{txid}"),
8090                output_index,
8091                proof: None,
8092                tx_metadata: None,
8093                output_metadata: None,
8094                inputs: None,
8095            })
8096        }
8097        async fn submit_node(
8098            &self,
8099            _: &crate::types::GASPNode,
8100        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
8101            Ok(None)
8102        }
8103    }
8104
8105    /// Mock factory that creates MockSyncRemote instances
8106    struct MockGASPRemoteFactory;
8107
8108    #[derive(Clone)]
8109    struct BudgetPageRemote {
8110        outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
8111        nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
8112        elapsed: std::rc::Rc<Cell<u64>>,
8113        requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
8114        clamp: Option<usize>,
8115        node_cost_ms: u64,
8116    }
8117
8118    #[async_trait(?Send)]
8119    impl crate::gasp::GASPRemote for BudgetPageRemote {
8120        async fn get_initial_response(
8121            &self,
8122            request: &crate::types::GASPInitialRequest,
8123        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
8124            self.requests.borrow_mut().push(request.clone());
8125            let page = self
8126                .outputs
8127                .iter()
8128                .filter(|u| u.score as u64 >= request.since);
8129            let limit = self.clamp.map_or(usize::MAX, |cap| {
8130                cap.min(request.limit.unwrap_or(u64::MAX) as usize)
8131            });
8132            Ok(crate::types::GASPInitialResponse {
8133                utxo_list: page.take(limit).cloned().collect(),
8134                since: request.since,
8135            })
8136        }
8137
8138        async fn get_initial_reply(
8139            &self,
8140            _: &crate::types::GASPInitialResponse,
8141        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
8142            unreachable!()
8143        }
8144
8145        async fn request_node(
8146            &self,
8147            graph_id: &str,
8148            txid: &str,
8149            _: u32,
8150            _: bool,
8151        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
8152            self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
8153            if self.node_cost_ms > 0 {
8154                let mut yielded = false;
8155                std::future::poll_fn(|cx| {
8156                    if yielded {
8157                        std::task::Poll::Ready(())
8158                    } else {
8159                        yielded = true;
8160                        cx.waker().wake_by_ref();
8161                        std::task::Poll::Pending
8162                    }
8163                })
8164                .await;
8165            }
8166            let mut node = self
8167                .nodes
8168                .get(txid)
8169                .cloned()
8170                .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
8171            node.graph_id = graph_id.to_string();
8172            Ok(node)
8173        }
8174
8175        async fn submit_node(
8176            &self,
8177            _: &crate::types::GASPNode,
8178        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
8179            unreachable!()
8180        }
8181    }
8182
8183    impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
8184        fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
8185            Box::new(self.clone())
8186        }
8187    }
8188
8189    fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
8190        let mut engine = make_engine(vec![0]);
8191        let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
8192        let mut outputs = Vec::new();
8193        let mut nodes = HashMap::new();
8194        for n in 1..=count {
8195            let tx = child_of(&format!("{n:064x}"), 0);
8196            let txid = tx.id();
8197            tracker.add_root(n, txid.clone());
8198            outputs.push(crate::types::GASPOutput {
8199                txid: txid.clone(),
8200                output_index: 0,
8201                score: f64::from(n * 10),
8202            });
8203            nodes.insert(
8204                txid.clone(),
8205                crate::types::GASPNode {
8206                    graph_id: String::new(),
8207                    raw_tx: tx.to_hex(),
8208                    output_index: 0,
8209                    proof: Some(single_leaf_bump_hex(&txid, n)),
8210                    tx_metadata: None,
8211                    output_metadata: None,
8212                    inputs: None,
8213                },
8214            );
8215        }
8216        engine.chain_tracker = Some(Box::new(tracker));
8217        engine.config.sync_configuration.insert(
8218            "tm_test".to_string(),
8219            SyncTarget::Peers(vec!["https://budget.example".to_string()]),
8220        );
8221        let remote = BudgetPageRemote {
8222            outputs: std::rc::Rc::new(outputs),
8223            nodes: std::rc::Rc::new(nodes),
8224            elapsed: std::rc::Rc::new(Cell::new(0)),
8225            requests: std::rc::Rc::default(),
8226            clamp: None,
8227            node_cost_ms: 5000,
8228        };
8229        (engine, remote)
8230    }
8231
8232    fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
8233        let elapsed = remote.elapsed.clone();
8234        engine.set_peer_sync_budget(
8235            std::rc::Rc::new(move |ms| {
8236                assert_eq!(ms, 30_000);
8237                let elapsed = elapsed.clone();
8238                let until = elapsed.get() + ms;
8239                Box::pin(std::future::poll_fn(move |_| {
8240                    if elapsed.get() >= until {
8241                        std::task::Poll::Ready(())
8242                    } else {
8243                        std::task::Poll::Pending
8244                    }
8245                }))
8246            }),
8247            30_000,
8248        );
8249    }
8250
8251    #[tokio::test]
8252    async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
8253        // This responder ignores the limit: the initiator must safely handle
8254        // a 3,000-output page under a virtual 30-second peer budget.
8255        let (mut engine, remote) = budget_page_engine(3000);
8256        virtual_peer_budget(&mut engine, &remote);
8257        engine.set_gasp_remote_factory(Box::new(remote.clone()));
8258        for round in 1..=2 {
8259            let result = engine.start_gasp_sync().await.unwrap();
8260            let topic = &result.topics_synced["tm_test"];
8261            assert_eq!(topic.finalized_graphs, 5);
8262            assert_eq!(topic.deadline_dropped_graphs, 1);
8263            assert_eq!(topic.cursor_moves.len(), 1);
8264            let expected = round * 50;
8265            assert_eq!(topic.cursor_moves[0].to, expected);
8266            assert_eq!(
8267                engine
8268                    .storage
8269                    .get_last_interaction("https://budget.example", "tm_test")
8270                    .await
8271                    .unwrap(),
8272                expected
8273            );
8274            {
8275                let requests = remote.requests.borrow();
8276                assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
8277            }
8278            for output in remote.outputs.iter().take((round * 5) as usize) {
8279                assert!(engine
8280                    .storage
8281                    .find_output(&output.txid, 0, Some("tm_test"), None, true)
8282                    .await
8283                    .unwrap()
8284                    .is_some());
8285            }
8286            let unfinished = &remote.outputs[(round * 5) as usize];
8287            assert!(engine
8288                .storage
8289                .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
8290                .await
8291                .unwrap()
8292                .is_none());
8293        }
8294    }
8295
8296    #[tokio::test]
8297    async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
8298        let (mut engine, mut remote) = budget_page_engine(7);
8299        remote.clamp = Some(3);
8300        remote.node_cost_ms = 0;
8301        virtual_peer_budget(&mut engine, &remote);
8302        engine.set_gasp_remote_factory(Box::new(remote.clone()));
8303        let result = engine.start_gasp_sync().await.unwrap();
8304        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
8305        let requests = remote.requests.borrow();
8306        assert_eq!(requests.len(), 4);
8307        assert!(requests.iter().all(|request| request.limit == Some(100)));
8308        assert_eq!(
8309            requests.iter().map(|r| r.since).collect::<Vec<_>>(),
8310            vec![0, 30, 50, 70]
8311        );
8312    }
8313
8314    #[tokio::test]
8315    async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
8316        let (mut engine, mut remote) = budget_page_engine(3000);
8317        std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
8318        virtual_peer_budget(&mut engine, &remote);
8319        engine.set_gasp_remote_factory(Box::new(remote));
8320        let result = engine.start_gasp_sync().await.unwrap();
8321        let topic = &result.topics_synced["tm_test"];
8322        assert_eq!(topic.finalized_graphs, 5);
8323        // Score 100 landed, but score 10 is still ahead in this unordered
8324        // page. The unfinished lower score must hold the cursor at entry.
8325        assert!(topic.cursor_moves.is_empty());
8326    }
8327
8328    #[tokio::test]
8329    async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
8330        let (mut engine, mut remote) = budget_page_engine(3);
8331        std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
8332        remote.node_cost_ms = 10_000;
8333        virtual_peer_budget(&mut engine, &remote);
8334        engine.set_gasp_remote_factory(Box::new(remote));
8335        let result = engine.start_gasp_sync().await.unwrap();
8336        let topic = &result.topics_synced["tm_test"];
8337        assert_eq!(topic.finalized_graphs, 2);
8338        assert_eq!(topic.cursor_moves[0].to, 10);
8339    }
8340
8341    #[tokio::test]
8342    async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
8343        let (mut engine, mut remote) = budget_page_engine(3000);
8344        let failed = remote.outputs[2].txid.clone();
8345        std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
8346        virtual_peer_budget(&mut engine, &remote);
8347        engine.set_gasp_remote_factory(Box::new(remote));
8348        let result = engine.start_gasp_sync().await.unwrap();
8349        let topic = &result.topics_synced["tm_test"];
8350        assert_eq!(topic.finalized_graphs, 4);
8351        assert_eq!(topic.cursor_moves[0].to, 20);
8352    }
8353
8354    struct ProofOnlyRootFetcher {
8355        proofs: HashMap<String, String>,
8356        requested: std::rc::Rc<Cell<u64>>,
8357    }
8358
8359    #[async_trait(?Send)]
8360    impl AncestorFetcher for ProofOnlyRootFetcher {
8361        async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
8362            Err(GASPError::RemoteError("raw source unavailable".to_string()))
8363        }
8364        async fn verified_proof_for(&self, txid: &str) -> Option<String> {
8365            self.requested.set(self.requested.get() + 1);
8366            self.proofs.get(txid).cloned()
8367        }
8368    }
8369
8370    #[tokio::test]
8371    async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
8372        for proven in [true, false] {
8373            let (mut engine, mut remote) = budget_page_engine(1);
8374            let txid = remote.outputs[0].txid.clone();
8375            let proof = remote.nodes[&txid].proof.clone().unwrap();
8376            std::rc::Rc::make_mut(&mut remote.nodes)
8377                .get_mut(&txid)
8378                .unwrap()
8379                .proof = None;
8380            let requested = std::rc::Rc::new(Cell::new(0));
8381            engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8382                proofs: if proven {
8383                    HashMap::from([(txid.clone(), proof)])
8384                } else {
8385                    HashMap::new()
8386                },
8387                requested: requested.clone(),
8388            }));
8389            virtual_peer_budget(&mut engine, &remote);
8390            engine.set_gasp_remote_factory(Box::new(remote));
8391            let result = engine.start_gasp_sync().await.unwrap();
8392            assert!(requested.get() > 0);
8393            assert_eq!(
8394                result.topics_synced["tm_test"].finalized_graphs,
8395                u64::from(proven)
8396            );
8397            assert_eq!(
8398                engine
8399                    .storage
8400                    .find_output(&txid, 0, Some("tm_test"), None, true)
8401                    .await
8402                    .unwrap()
8403                    .is_some(),
8404                proven
8405            );
8406            if !proven {
8407                assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
8408            }
8409        }
8410    }
8411
8412    #[tokio::test]
8413    async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
8414        let (mut engine, mut remote) = budget_page_engine(1);
8415        let txid = remote.outputs[0].txid.clone();
8416        let proof = remote.nodes[&txid].proof.clone().unwrap();
8417        std::rc::Rc::make_mut(&mut remote.nodes)
8418            .get_mut(&txid)
8419            .unwrap()
8420            .proof = None;
8421        let requested = std::rc::Rc::new(Cell::new(0));
8422        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8423            proofs: HashMap::new(),
8424            requested: requested.clone(),
8425        }));
8426        virtual_peer_budget(&mut engine, &remote);
8427        engine.set_graph_budget(
8428            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
8429            5,
8430            5000,
8431        );
8432        engine.set_gasp_remote_factory(Box::new(remote));
8433        let held = engine.start_gasp_sync().await.unwrap();
8434        assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
8435        assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
8436        assert!(held.topics_synced["tm_test"].cursor_moves.is_empty());
8437        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8438            proofs: HashMap::from([(txid.clone(), proof)]),
8439            requested,
8440        }));
8441        let landed = engine.start_gasp_sync().await.unwrap();
8442        assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
8443        assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
8444        assert_eq!(landed.topics_synced["tm_test"].cursor_moves[0].to, 10);
8445        assert!(engine
8446            .storage
8447            .find_output(&txid, 0, Some("tm_test"), None, true)
8448            .await
8449            .unwrap()
8450            .is_some());
8451    }
8452
8453    #[tokio::test]
8454    async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
8455        let (mut engine, mut remote) = budget_page_engine(1);
8456        let txid = remote.outputs[0].txid.clone();
8457        std::rc::Rc::make_mut(&mut remote.nodes)
8458            .get_mut(&txid)
8459            .unwrap()
8460            .proof = None;
8461        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8462            proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
8463            requested: std::rc::Rc::default(),
8464        }));
8465        virtual_peer_budget(&mut engine, &remote);
8466        engine.set_gasp_remote_factory(Box::new(remote));
8467        let result = engine.start_gasp_sync().await.unwrap();
8468        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
8469        assert!(engine
8470            .storage
8471            .find_output(&txid, 0, Some("tm_test"), None, true)
8472            .await
8473            .unwrap()
8474            .is_none());
8475    }
8476
8477    impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
8478        fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8479            Box::new(MockSyncRemote {
8480                utxos: vec![
8481                    crate::types::GASPOutput {
8482                        txid: "remote_tx1".to_string(),
8483                        output_index: 0,
8484                        score: 100.0,
8485                    },
8486                    crate::types::GASPOutput {
8487                        txid: "remote_tx2".to_string(),
8488                        output_index: 0,
8489                        score: 200.0,
8490                    },
8491                ],
8492            })
8493        }
8494    }
8495
8496    #[tokio::test]
8497    async fn test_start_gasp_sync_with_factory_runs_sync() {
8498        let mut sync_config: SyncConfiguration = HashMap::new();
8499        sync_config.insert(
8500            "tm_test".to_string(),
8501            SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
8502        );
8503
8504        let mut engine = Engine::new(
8505            HashMap::new(),
8506            HashMap::new(),
8507            Box::new(MemoryStorage::new()),
8508            None,
8509            EngineConfig {
8510                sync_configuration: sync_config,
8511                ..Default::default()
8512            },
8513        );
8514
8515        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
8516
8517        let result = engine.start_gasp_sync().await.unwrap();
8518        assert_eq!(result.topics_synced.len(), 1);
8519
8520        let topic_result = &result.topics_synced["tm_test"];
8521        assert_eq!(topic_result.peers.len(), 1);
8522        assert!(
8523            topic_result.errors.is_empty(),
8524            "Sync should succeed with mock remote"
8525        );
8526
8527        // Verify last_interaction was persisted
8528        let last = engine
8529            .storage()
8530            .get_last_interaction("https://peer1.example.com", "tm_test")
8531            .await
8532            .unwrap();
8533        // Synthetic nodes cannot assemble into BEEF. No score completed,
8534        // so the entry cursor stays and the next sync re-requests both.
8535        assert_eq!(
8536            last, 0,
8537            "cursor must remain at entry when no output completed"
8538        );
8539    }
8540
8541    #[tokio::test]
8542    async fn test_start_gasp_sync_with_factory_handles_error() {
8543        struct FailingFactory;
8544
8545        impl crate::gasp::GASPRemoteFactory for FailingFactory {
8546            fn create_remote(
8547                &self,
8548                _peer_url: &str,
8549                _topic: &str,
8550            ) -> Box<dyn crate::gasp::GASPRemote> {
8551                struct FailingRemote;
8552                #[async_trait(?Send)]
8553                impl crate::gasp::GASPRemote for FailingRemote {
8554                    async fn get_initial_response(
8555                        &self,
8556                        _: &crate::types::GASPInitialRequest,
8557                    ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
8558                    {
8559                        Err(crate::gasp::GASPError::RemoteError(
8560                            "connection refused".into(),
8561                        ))
8562                    }
8563                    async fn get_initial_reply(
8564                        &self,
8565                        _: &crate::types::GASPInitialResponse,
8566                    ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
8567                    {
8568                        unreachable!()
8569                    }
8570                    async fn request_node(
8571                        &self,
8572                        _: &str,
8573                        _: &str,
8574                        _: u32,
8575                        _: bool,
8576                    ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
8577                    {
8578                        unreachable!()
8579                    }
8580                    async fn submit_node(
8581                        &self,
8582                        _: &crate::types::GASPNode,
8583                    ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
8584                    {
8585                        unreachable!()
8586                    }
8587                }
8588                Box::new(FailingRemote)
8589            }
8590        }
8591
8592        let mut sync_config: SyncConfiguration = HashMap::new();
8593        sync_config.insert(
8594            "tm_test".to_string(),
8595            SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
8596        );
8597
8598        let mut engine = Engine::new(
8599            HashMap::new(),
8600            HashMap::new(),
8601            Box::new(MemoryStorage::new()),
8602            None,
8603            EngineConfig {
8604                sync_configuration: sync_config,
8605                ..Default::default()
8606            },
8607        );
8608
8609        engine.set_gasp_remote_factory(Box::new(FailingFactory));
8610
8611        let result = engine.start_gasp_sync().await.unwrap();
8612        assert_eq!(result.topics_synced.len(), 1);
8613
8614        let topic_result = &result.topics_synced["tm_test"];
8615        assert_eq!(
8616            topic_result.errors.len(),
8617            1,
8618            "Should have one error for the failing peer"
8619        );
8620        assert!(topic_result.errors[0].contains("connection refused"));
8621    }
8622
8623    #[tokio::test]
8624    async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
8625        let mut sync_config: SyncConfiguration = HashMap::new();
8626        sync_config.insert(
8627            "tm_test".to_string(),
8628            SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
8629        );
8630
8631        // No factory set — should still work (just peer discovery, no sync)
8632        let engine = Engine::new(
8633            HashMap::new(),
8634            HashMap::new(),
8635            Box::new(MemoryStorage::new()),
8636            None,
8637            EngineConfig {
8638                sync_configuration: sync_config,
8639                ..Default::default()
8640            },
8641        );
8642
8643        let result = engine.start_gasp_sync().await.unwrap();
8644        assert_eq!(result.topics_synced.len(), 1);
8645
8646        let topic_result = &result.topics_synced["tm_test"];
8647        assert_eq!(topic_result.peers.len(), 1);
8648        assert!(topic_result.errors.is_empty());
8649    }
8650
8651    #[tokio::test]
8652    async fn test_start_gasp_sync_with_factory_multiple_peers() {
8653        let mut sync_config: SyncConfiguration = HashMap::new();
8654        sync_config.insert(
8655            "tm_test".to_string(),
8656            SyncTarget::Peers(vec![
8657                "https://peer1.example.com".to_string(),
8658                "https://peer2.example.com".to_string(),
8659            ]),
8660        );
8661
8662        let mut engine = Engine::new(
8663            HashMap::new(),
8664            HashMap::new(),
8665            Box::new(MemoryStorage::new()),
8666            None,
8667            EngineConfig {
8668                sync_configuration: sync_config,
8669                ..Default::default()
8670            },
8671        );
8672
8673        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
8674
8675        let result = engine.start_gasp_sync().await.unwrap();
8676        let topic_result = &result.topics_synced["tm_test"];
8677        assert_eq!(topic_result.peers.len(), 2);
8678        assert!(topic_result.errors.is_empty());
8679
8680        // Neither peer completed its synthetic graphs.
8681        let last1 = engine
8682            .storage()
8683            .get_last_interaction("https://peer1.example.com", "tm_test")
8684            .await
8685            .unwrap();
8686        let last2 = engine
8687            .storage()
8688            .get_last_interaction("https://peer2.example.com", "tm_test")
8689            .await
8690            .unwrap();
8691        assert_eq!(last1, 0);
8692        assert_eq!(last2, 0);
8693    }
8694
8695    // ── Per-peer budget + dead-peer quarantine (bsv-low#302) ──────────
8696
8697    /// A remote whose initial request HANGS forever — models the dead
8698    /// ephemeral peers (ngrok tunnels) whose fetch never returns, the #257
8699    /// root cause the per-peer budget exists for.
8700    struct HangingRemote;
8701
8702    #[async_trait(?Send)]
8703    impl crate::gasp::GASPRemote for HangingRemote {
8704        async fn get_initial_response(
8705            &self,
8706            _: &crate::types::GASPInitialRequest,
8707        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
8708            std::future::pending::<()>().await;
8709            unreachable!()
8710        }
8711        async fn get_initial_reply(
8712            &self,
8713            _: &crate::types::GASPInitialResponse,
8714        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
8715            unreachable!()
8716        }
8717        async fn request_node(
8718            &self,
8719            _: &str,
8720            _: &str,
8721            _: u32,
8722            _: bool,
8723        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
8724            unreachable!()
8725        }
8726        async fn submit_node(
8727            &self,
8728            _: &crate::types::GASPNode,
8729        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
8730            unreachable!()
8731        }
8732    }
8733
8734    /// Factory that HANGS for `hang.example.com` peers and serves the normal
8735    /// mock sync remote for everyone else, counting `create_remote` calls
8736    /// (a quarantine-skipped peer never reaches the factory).
8737    struct SelectiveHangFactory {
8738        created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
8739    }
8740
8741    impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
8742        fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8743            self.created.borrow_mut().push(peer_url.to_string());
8744            if peer_url.contains("hang.example.com") {
8745                Box::new(HangingRemote)
8746            } else {
8747                Box::new(MockSyncRemote {
8748                    utxos: vec![crate::types::GASPOutput {
8749                        txid: "remote_tx1".to_string(),
8750                        output_index: 0,
8751                        score: 100.0,
8752                    }],
8753                })
8754            }
8755        }
8756    }
8757
8758    /// The deterministic "instant deadline" sleep factory: `race_or_deadline`
8759    /// polls the sync future FIRST, so an in-memory sync that never yields
8760    /// still completes; only a future that actually returns Pending (the
8761    /// hanging remote) loses the race.
8762    fn instant_deadline() -> crate::engine::SleepFactory {
8763        std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
8764    }
8765
8766    fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
8767        let mut sync_config: SyncConfiguration = HashMap::new();
8768        sync_config.insert(
8769            "tm_test".to_string(),
8770            SyncTarget::Peers(vec![
8771                "https://hang.example.com".to_string(),
8772                "https://good.example.com".to_string(),
8773            ]),
8774        );
8775        Engine::new(
8776            HashMap::new(),
8777            HashMap::new(),
8778            Box::new(store),
8779            None,
8780            EngineConfig {
8781                sync_configuration: sync_config,
8782                ..Default::default()
8783            },
8784        )
8785    }
8786
8787    #[tokio::test]
8788    async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
8789        let store = std::rc::Rc::new(MemoryStorage::new());
8790        let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8791        let mut engine = two_peer_engine(store.clone());
8792        engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
8793            created: created.clone(),
8794        }));
8795        engine.set_peer_sync_budget(instant_deadline(), 1);
8796
8797        let result = engine.start_gasp_sync().await.unwrap();
8798        let topic_result = &result.topics_synced["tm_test"];
8799
8800        // The hanging peer was dropped LOUDLY (an error entry) and the loop
8801        // CONTINUED: the good peer was still attempted and completed.
8802        assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
8803        assert!(
8804            topic_result.errors[0].contains("budget"),
8805            "{:?}",
8806            topic_result.errors
8807        );
8808        assert_eq!(
8809            created.borrow().as_slice(),
8810            ["https://hang.example.com", "https://good.example.com"],
8811            "both peers attempted this tick"
8812        );
8813
8814        // A timed-out peer stays at entry. The other peer was reached but
8815        // served synthetic graphs that cannot complete, so its cursor stays too.
8816        let hang_cursor = store
8817            .get_last_interaction("https://hang.example.com", "tm_test")
8818            .await
8819            .unwrap();
8820        assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
8821        let good_cursor = store
8822            .get_last_interaction("https://good.example.com", "tm_test")
8823            .await
8824            .unwrap();
8825        assert_eq!(good_cursor, 0, "no synthetic graph completed");
8826
8827        // Health bookkeeping: timeout = failure, completion = success.
8828        let hang_health = store
8829            .get_peer_sync_health("hang.example.com", "tm_test")
8830            .await
8831            .unwrap();
8832        assert_eq!(hang_health.consecutive_failures, 1);
8833        let good_health = store
8834            .get_peer_sync_health("good.example.com", "tm_test")
8835            .await
8836            .unwrap();
8837        assert_eq!(good_health.consecutive_failures, 0);
8838    }
8839
8840    #[tokio::test]
8841    async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
8842        let store = std::rc::Rc::new(MemoryStorage::new());
8843        let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8844
8845        let mut sync_config: SyncConfiguration = HashMap::new();
8846        sync_config.insert(
8847            "tm_test".to_string(),
8848            SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
8849        );
8850        let mut engine = Engine::new(
8851            HashMap::new(),
8852            HashMap::new(),
8853            Box::new(store.clone()),
8854            None,
8855            EngineConfig {
8856                sync_configuration: sync_config,
8857                ..Default::default()
8858            },
8859        );
8860        engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
8861            created: created.clone(),
8862        }));
8863        engine.set_peer_sync_budget(instant_deadline(), 1);
8864
8865        // Fail the peer up to (but not past) the quarantine threshold — every
8866        // tick attempts it (advance the clock between ticks so the attempts
8867        // are distinguishable in age).
8868        for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
8869            engine.start_gasp_sync().await.unwrap();
8870            store.advance_clock(900);
8871            assert_eq!(
8872                created.borrow().len() as u64,
8873                i + 1,
8874                "peer attempted while below the threshold"
8875            );
8876        }
8877        let health = store
8878            .get_peer_sync_health("hang.example.com", "tm_test")
8879            .await
8880            .unwrap();
8881        assert_eq!(
8882            health.consecutive_failures,
8883            crate::gasp::PEER_QUARANTINE_THRESHOLD
8884        );
8885
8886        // Next tick: QUARANTINED — the factory is never consulted, no error
8887        // is emitted, and the attempt count does not grow (a skip is not an
8888        // attempt, so the re-probe window keeps aging).
8889        let result = engine.start_gasp_sync().await.unwrap();
8890        assert!(result.topics_synced["tm_test"].errors.is_empty());
8891        assert_eq!(
8892            created.borrow().len() as u64,
8893            crate::gasp::PEER_QUARANTINE_THRESHOLD,
8894            "quarantined peer must be skipped"
8895        );
8896
8897        // Age past the re-probe window: exactly one fresh probe is allowed.
8898        store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
8899        engine.start_gasp_sync().await.unwrap();
8900        assert_eq!(
8901            created.borrow().len() as u64,
8902            crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
8903            "re-probe attempted after the window"
8904        );
8905        // The probe failed again → failure count kept growing → re-armed.
8906        let health = store
8907            .get_peer_sync_health("hang.example.com", "tm_test")
8908            .await
8909            .unwrap();
8910        assert_eq!(
8911            health.consecutive_failures,
8912            crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
8913        );
8914        let result = engine.start_gasp_sync().await.unwrap();
8915        assert!(
8916            result.topics_synced["tm_test"].errors.is_empty(),
8917            "re-quarantined"
8918        );
8919
8920        // The peer comes back to life: age to the next re-probe, then answer
8921        // like a healthy peer → SUCCESS resets the count to 0 (full
8922        // re-admission — quarantine is never a deletion).
8923        store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
8924        // Swap the factory for one that serves the healthy mock for this URL.
8925        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
8926        let result = engine.start_gasp_sync().await.unwrap();
8927        assert!(result.topics_synced["tm_test"].errors.is_empty());
8928        let health = store
8929            .get_peer_sync_health("hang.example.com", "tm_test")
8930            .await
8931            .unwrap();
8932        assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
8933    }
8934
8935    // ── Mock ARC Broadcaster ─────────────────────────────────────────
8936
8937    use crate::broadcaster::ArcBroadcaster;
8938
8939    struct MockArcBroadcaster {
8940        calls: Arc<Mutex<Vec<String>>>,
8941        should_fail: bool,
8942    }
8943
8944    impl MockArcBroadcaster {
8945        fn new() -> Self {
8946            Self {
8947                calls: Arc::new(Mutex::new(Vec::new())),
8948                should_fail: false,
8949            }
8950        }
8951
8952        fn failing() -> Self {
8953            Self {
8954                calls: Arc::new(Mutex::new(Vec::new())),
8955                should_fail: true,
8956            }
8957        }
8958    }
8959
8960    #[async_trait(?Send)]
8961    impl ArcBroadcaster for MockArcBroadcaster {
8962        async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
8963            self.calls.lock().unwrap().push(raw_tx_hex.to_string());
8964            if self.should_fail {
8965                Err("mock ARC failure".to_string())
8966            } else {
8967                Ok("mock_txid_from_arc".to_string())
8968            }
8969        }
8970    }
8971
8972    /// Create engine with an ARC broadcaster (and optionally a SHIP broadcaster).
8973    fn make_engine_with_arc(
8974        admit_indices: Vec<u32>,
8975        arc: MockArcBroadcaster,
8976    ) -> (Engine, Arc<Mutex<Vec<String>>>) {
8977        let arc_calls = arc.calls.clone();
8978
8979        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8980        managers.insert(
8981            "tm_test".to_string(),
8982            Box::new(MockTopicManager::admitting(admit_indices)),
8983        );
8984
8985        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8986        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
8987
8988        let storage = Box::new(MemoryStorage::new());
8989
8990        let engine = Engine::with_all(
8991            managers,
8992            lookup_services,
8993            storage,
8994            None,
8995            None,
8996            Some(Box::new(arc)),
8997            None,
8998            EngineConfig::default(),
8999        );
9000
9001        (engine, arc_calls)
9002    }
9003
9004    // ── ARC Broadcaster Tests ────────────────────────────────────────
9005
9006    #[tokio::test]
9007    async fn test_arc_broadcaster_called_on_current_tx() {
9008        let arc = MockArcBroadcaster::new();
9009        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9010
9011        let beef = test_tagged_beef(vec!["tm_test"]);
9012        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
9013
9014        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
9015
9016        let calls = arc_calls.lock().unwrap();
9017        assert_eq!(
9018            calls.len(),
9019            1,
9020            "ARC broadcaster should be called once for CurrentTx"
9021        );
9022        assert!(
9023            !calls[0].is_empty(),
9024            "ARC should receive non-empty raw tx hex"
9025        );
9026    }
9027
9028    #[tokio::test]
9029    async fn test_arc_broadcaster_not_called_on_historical_tx() {
9030        let arc = MockArcBroadcaster::new();
9031        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9032
9033        let beef = test_tagged_beef(vec!["tm_test"]);
9034        engine
9035            .submit(&beef, SubmitMode::HistoricalTx)
9036            .await
9037            .unwrap();
9038
9039        assert_eq!(
9040            arc_calls.lock().unwrap().len(),
9041            0,
9042            "ARC broadcaster should NOT be called for HistoricalTx"
9043        );
9044    }
9045
9046    #[tokio::test]
9047    async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
9048        let arc = MockArcBroadcaster::new();
9049        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9050
9051        let beef = test_tagged_beef(vec!["tm_test"]);
9052        engine
9053            .submit(&beef, SubmitMode::HistoricalTxNoSpv)
9054            .await
9055            .unwrap();
9056
9057        assert_eq!(
9058            arc_calls.lock().unwrap().len(),
9059            0,
9060            "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
9061        );
9062    }
9063
9064    #[tokio::test]
9065    async fn test_arc_broadcast_failure_does_not_fail_submit() {
9066        let arc = MockArcBroadcaster::failing();
9067        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9068
9069        let beef = test_tagged_beef(vec!["tm_test"]);
9070        // submit should succeed even when ARC fails
9071        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
9072
9073        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
9074
9075        // ARC was called (and failed), but submit still succeeded
9076        assert_eq!(
9077            arc_calls.lock().unwrap().len(),
9078            1,
9079            "ARC broadcaster should be attempted even if it will fail"
9080        );
9081    }
9082
9083    #[tokio::test]
9084    async fn test_arc_broadcaster_not_present_still_works() {
9085        // Engine without ARC broadcaster — should work fine
9086        let engine = make_engine(vec![0]);
9087
9088        let beef = test_tagged_beef(vec!["tm_test"]);
9089        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
9090
9091        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
9092    }
9093
9094    // ── Sync advertisements URL validation ─────────────────────────────
9095
9096    /// Mock Advertiser that tracks created advertisements via shared state.
9097    struct TrackingAdvertiser {
9098        created: Arc<Mutex<Vec<AdvertisementData>>>,
9099        existing_ship: Vec<Advertisement>,
9100        existing_slap: Vec<Advertisement>,
9101        /// Topics the returned TaggedBEEF is tagged with — a topic with no
9102        /// registered manager forces the LOCAL submit to fail (#320 3a);
9103        /// several topics exercise independent per-topic admission (D1).
9104        tag_topics: Vec<String>,
9105        /// Simulate a create_advertisements failure (#320 3a).
9106        create_fails: bool,
9107        /// Simulate a find_all_advertisements failure (#320 M2).
9108        find_fails: bool,
9109        /// Return the v1 no-op EMPTY TaggedBEEF from revoke (#320 L2).
9110        revoke_empty: bool,
9111    }
9112
9113    impl TrackingAdvertiser {
9114        fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
9115            let created = Arc::new(Mutex::new(Vec::new()));
9116            (
9117                Self {
9118                    created: created.clone(),
9119                    existing_ship: vec![],
9120                    existing_slap: vec![],
9121                    tag_topics: vec!["tm_test".to_string()],
9122                    create_fails: false,
9123                    find_fails: false,
9124                    revoke_empty: false,
9125                },
9126                created,
9127            )
9128        }
9129    }
9130
9131    use crate::advertiser::{Advertiser, AdvertiserError};
9132
9133    #[async_trait(?Send)]
9134    impl Advertiser for TrackingAdvertiser {
9135        async fn create_advertisements(
9136            &self,
9137            ads: &[AdvertisementData],
9138        ) -> Result<TaggedBEEF, AdvertiserError> {
9139            if self.create_fails {
9140                return Err(AdvertiserError::CreationFailed(
9141                    "mock create refused".into(),
9142                ));
9143            }
9144            self.created.lock().unwrap().extend(ads.iter().cloned());
9145            Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
9146        }
9147
9148        async fn find_all_advertisements(
9149            &self,
9150            protocol: Protocol,
9151        ) -> Result<Vec<Advertisement>, AdvertiserError> {
9152            if self.find_fails {
9153                return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
9154            }
9155            match protocol {
9156                Protocol::Ship => Ok(self.existing_ship.clone()),
9157                Protocol::Slap => Ok(self.existing_slap.clone()),
9158            }
9159        }
9160
9161        async fn revoke_advertisements(
9162            &self,
9163            _ads: &[Advertisement],
9164        ) -> Result<TaggedBEEF, AdvertiserError> {
9165            if self.revoke_empty {
9166                return Ok(TaggedBEEF::new(vec![], vec![]));
9167            }
9168            Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
9169        }
9170
9171        fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
9172            None
9173        }
9174    }
9175
9176    /// TS: "Sync advertisements URL validation"
9177    /// sync_advertisements should be a no-op when hosting_url is empty string,
9178    /// and should skip suppressed topics (tm_ship/tm_slap) when configured.
9179    #[tokio::test]
9180    async fn test_sync_advertisements_url_validation() {
9181        // Case 1: Empty hosting URL — should return early without creating ads
9182        let (adv, created) = TrackingAdvertiser::new();
9183
9184        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9185        managers.insert(
9186            "tm_test".to_string(),
9187            Box::new(MockTopicManager::admitting(vec![0])),
9188        );
9189
9190        let engine = Engine::new(
9191            managers,
9192            HashMap::new(),
9193            Box::new(MemoryStorage::new()),
9194            Some(Box::new(adv)),
9195            EngineConfig {
9196                hosting_url: Some(String::new()), // empty string — invalid
9197                ..Default::default()
9198            },
9199        );
9200
9201        engine.sync_advertisements().await.unwrap();
9202        assert!(
9203            created.lock().unwrap().is_empty(),
9204            "Empty hosting URL should skip advertisement creation"
9205        );
9206
9207        // Case 2: Valid hosting URL — should create SHIP advertisements for non-suppressed topics
9208        let (adv2, created2) = TrackingAdvertiser::new();
9209
9210        let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9211        managers2.insert(
9212            "tm_test".to_string(),
9213            Box::new(MockTopicManager::admitting(vec![0])),
9214        );
9215        // Also register tm_ship to verify it gets suppressed
9216        managers2.insert(
9217            "tm_ship".to_string(),
9218            Box::new(MockTopicManager::admitting(vec![])),
9219        );
9220
9221        let engine2 = Engine::new(
9222            managers2,
9223            HashMap::new(),
9224            Box::new(MemoryStorage::new()),
9225            Some(Box::new(adv2)),
9226            EngineConfig {
9227                hosting_url: Some("https://valid.example.com".to_string()),
9228                suppress_default_sync_advertisements: true,
9229                ..Default::default()
9230            },
9231        );
9232
9233        engine2.sync_advertisements().await.unwrap();
9234        let ads = created2.lock().unwrap();
9235        // tm_ship should be suppressed, only tm_test should get an advertisement
9236        assert!(
9237            ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
9238            "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
9239        );
9240        assert!(
9241            ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
9242            "tm_test should get a SHIP advertisement"
9243        );
9244    }
9245
9246    // ── sync_advertisements report (bsv-low #320 defect 3a) ────────────
9247    //
9248    // Pre-#320 the engine returned `Ok(())` unconditionally: a failed
9249    // create or a failed LOCAL submit was `error!`-logged and invisible to
9250    // the caller — the admin route said `success` while the node's own
9251    // ls_ship/ls_slap never gained its ads, so every cycle re-created the
9252    // whole set. These cells pin the report contract.
9253
9254    fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
9255        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9256        managers.insert(
9257            "tm_test".to_string(),
9258            Box::new(MockTopicManager::admitting(vec![0])),
9259        );
9260        Engine::new(
9261            managers,
9262            HashMap::new(),
9263            Box::new(MemoryStorage::new()),
9264            Some(Box::new(adv)),
9265            EngineConfig {
9266                hosting_url: Some("https://valid.example.com".to_string()),
9267                suppress_default_sync_advertisements: true,
9268                ..Default::default()
9269            },
9270        )
9271    }
9272
9273    #[tokio::test]
9274    async fn sync_report_counts_admitted_outputs_on_success() {
9275        let (adv, _) = TrackingAdvertiser::new();
9276        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9277        assert!(report.ok(), "clean run must report ok: {report:?}");
9278        assert_eq!(report.to_create, 1, "tm_test was missing");
9279        assert_eq!(
9280            report.admitted.get("tm_test"),
9281            Some(&1),
9282            "the STEAK's admitted count must be surfaced: {report:?}"
9283        );
9284    }
9285
9286    #[tokio::test]
9287    async fn sync_report_surfaces_local_submit_failure() {
9288        // The TaggedBEEF comes back tagged for a topic with no registered
9289        // manager → the LOCAL submit fails. That failure must be in the
9290        // report, not swallowed (the live silent-failure shape).
9291        let (mut adv, _) = TrackingAdvertiser::new();
9292        adv.tag_topics = vec!["tm_unregistered".to_string()];
9293        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9294        assert!(!report.ok(), "a failed local submit must not report ok");
9295        assert_eq!(report.to_create, 1);
9296        let submit_error = report.submit_error.as_deref().unwrap_or_default();
9297        assert!(
9298            submit_error.contains("tm_unregistered"),
9299            "verbatim engine error expected, got: {submit_error}"
9300        );
9301        assert!(report.admitted.is_empty());
9302    }
9303
9304    #[tokio::test]
9305    async fn sync_report_surfaces_create_failure() {
9306        let (mut adv, _) = TrackingAdvertiser::new();
9307        adv.create_fails = true;
9308        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9309        assert!(!report.ok());
9310        assert!(
9311            report
9312                .create_error
9313                .as_deref()
9314                .unwrap_or_default()
9315                .contains("mock create refused"),
9316            "{report:?}"
9317        );
9318        assert!(report.submit_error.is_none(), "create failed before submit");
9319    }
9320
9321    #[tokio::test]
9322    async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
9323        // A node whose own storage already lists its ads must attempt ZERO
9324        // creates — the convergence direction of #320 defect 3 (live, the
9325        // missing self-admission made every cycle re-create all 17 ads).
9326        let (mut adv, created) = TrackingAdvertiser::new();
9327        adv.existing_ship = vec![Advertisement {
9328            protocol: Protocol::Ship,
9329            identity_key: "02aa".to_string(),
9330            domain: "https://valid.example.com".to_string(),
9331            topic_or_service: "tm_test".to_string(),
9332            beef: None,
9333            output_index: None,
9334        }];
9335        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9336        assert!(report.ok(), "{report:?}");
9337        assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
9338        assert!(created.lock().unwrap().is_empty());
9339        assert!(
9340            report.effective(),
9341            "converged-noop is the one zero-admit shape that IS success"
9342        );
9343    }
9344
9345    #[tokio::test]
9346    async fn sync_report_zero_admit_is_ok_but_not_effective() {
9347        // #320 M1: the local submit SUCCEEDS but the topic manager admits
9348        // ZERO outputs — a non-error refusal. The ads never enter our own
9349        // ls_ship/ls_slap, so the diff re-creates (re-pays) the full set
9350        // next cycle. `ok()` (errors-only) stays true; `effective()` must
9351        // refuse, and the route keys success on `effective()`.
9352        let (adv, _) = TrackingAdvertiser::new();
9353        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9354        managers.insert(
9355            "tm_test".to_string(),
9356            Box::new(MockTopicManager::admitting(vec![])), // refuses everything
9357        );
9358        let engine = Engine::new(
9359            managers,
9360            HashMap::new(),
9361            Box::new(MemoryStorage::new()),
9362            Some(Box::new(adv)),
9363            EngineConfig {
9364                hosting_url: Some("https://valid.example.com".to_string()),
9365                suppress_default_sync_advertisements: true,
9366                ..Default::default()
9367            },
9368        );
9369        let report = engine.sync_advertisements().await.unwrap();
9370        assert!(report.ok(), "no ERROR occurred: {report:?}");
9371        assert_eq!(report.to_create, 1);
9372        assert_eq!(report.admitted_total(), 0, "{report:?}");
9373        assert!(
9374            !report.effective(),
9375            "zero admitted on a non-empty create must not read as success: {report:?}"
9376        );
9377    }
9378
9379    #[tokio::test]
9380    async fn sync_report_partial_admit_is_not_effective() {
9381        // Delta D1: TWO topics in one submitted tx, per-topic admission
9382        // independent — tm_test admits its output, tm_refuse refuses all.
9383        // admitted_total (1) > 0 but < to_create (2): the refused ads
9384        // would be re-created (re-paid) every cycle, so this must NOT
9385        // read as success.
9386        let (mut adv, _) = TrackingAdvertiser::new();
9387        adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
9388        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9389        managers.insert(
9390            "tm_test".to_string(),
9391            Box::new(MockTopicManager::admitting(vec![0])),
9392        );
9393        managers.insert(
9394            "tm_refuse".to_string(),
9395            Box::new(MockTopicManager::admitting(vec![])), // refuses everything
9396        );
9397        let engine = Engine::new(
9398            managers,
9399            HashMap::new(),
9400            Box::new(MemoryStorage::new()),
9401            Some(Box::new(adv)),
9402            EngineConfig {
9403                hosting_url: Some("https://valid.example.com".to_string()),
9404                suppress_default_sync_advertisements: true,
9405                ..Default::default()
9406            },
9407        );
9408        let report = engine.sync_advertisements().await.unwrap();
9409        assert!(report.ok(), "no ERROR occurred: {report:?}");
9410        assert_eq!(report.to_create, 2, "{report:?}");
9411        assert_eq!(report.admitted_total(), 1, "{report:?}");
9412        assert!(
9413            !report.effective(),
9414            "a partial admit must not read as success: {report:?}"
9415        );
9416    }
9417
9418    #[tokio::test]
9419    async fn sync_report_lookup_failure_refuses_creation() {
9420        // #320 M2: a transient read failure must never become "current ads
9421        // = none" and re-create (re-pay) the whole set — creation is
9422        // REFUSED and the failure lands in the report.
9423        let (mut adv, created) = TrackingAdvertiser::new();
9424        adv.find_fails = true;
9425        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9426        assert!(!report.ok(), "{report:?}");
9427        assert!(!report.effective());
9428        assert!(
9429            report
9430                .lookup_error
9431                .as_deref()
9432                .unwrap_or_default()
9433                .contains("mock lookup refused"),
9434            "{report:?}"
9435        );
9436        assert_eq!(report.to_create, 0, "creation refused, not attempted");
9437        assert!(
9438            created.lock().unwrap().is_empty(),
9439            "no create call on a blind read"
9440        );
9441        assert!(report.submit_error.is_none());
9442    }
9443
9444    #[tokio::test]
9445    async fn sync_report_counts_skipped_revocations() {
9446        // #320 L2: `to_revoke: N` with a v1 no-op advertiser must be
9447        // distinguishable from a revoke that actually ran.
9448        let (mut adv, _) = TrackingAdvertiser::new();
9449        adv.revoke_empty = true;
9450        adv.existing_ship = vec![
9451            Advertisement {
9452                protocol: Protocol::Ship,
9453                identity_key: "02aa".to_string(),
9454                domain: "https://valid.example.com".to_string(),
9455                topic_or_service: "tm_test".to_string(),
9456                beef: None,
9457                output_index: None,
9458            },
9459            Advertisement {
9460                protocol: Protocol::Ship,
9461                identity_key: "02aa".to_string(),
9462                domain: "https://valid.example.com".to_string(),
9463                topic_or_service: "tm_stale".to_string(), // no longer configured
9464                beef: None,
9465                output_index: None,
9466            },
9467        ];
9468        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9469        assert!(report.ok(), "{report:?}");
9470        assert_eq!(report.to_revoke, 1, "tm_stale is stale");
9471        assert_eq!(
9472            report.revoke_skipped, 1,
9473            "the v1 no-op must be visible, not silent: {report:?}"
9474        );
9475        assert!(report.revoke_error.is_none());
9476        assert!(report.revoke_submit_error.is_none());
9477    }
9478
9479    // ── complete_missing_proofs (#130) ─────────────────────────────────
9480
9481    use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
9482    use std::cell::Cell;
9483
9484    /// A single raw mainnet tx (no proof) — used to build a proofless BEEF.
9485    const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
9486
9487    /// Build a proofless single-tx BEEF + return `(beef_bytes, txid)`.
9488    fn proofless_beef() -> (Vec<u8>, String) {
9489        use bsv_rs::transaction::{Beef, Transaction};
9490        let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
9491        let txid = tx.id();
9492        let mut beef = Beef::new();
9493        beef.merge_transaction(tx);
9494        (beef.to_binary(), txid)
9495    }
9496
9497    // ── #284: stitching a proof must never rebuild the stored BEEF ─────────
9498
9499    /// The corruption shape observed live on rust-beta: a stored BEEF whose
9500    /// SUBJECT is not the last tx in wire order. The old rewrite went through
9501    /// `Transaction::from_beef(bytes, None)`, whose None arm picks
9502    /// `txs.last()` — the PARENT here — and `to_beef(true)` then serialized the
9503    /// parent's world, dropping the subject from its own row. TEST_BEEF_HEX is
9504    /// exactly a two-tx chain (parent + child spending it), so re-encoding it
9505    /// child-first reproduces the trigger byte-for-byte.
9506    fn subject_not_last_beef() -> (Vec<u8>, String, String) {
9507        use bsv_rs::transaction::Beef;
9508        let parsed = Beef::from_binary(&test_beef()).unwrap();
9509        let parent = parsed.txs[0].tx().unwrap().clone();
9510        let child = parsed.txs[1].tx().unwrap().clone();
9511        let (parent_id, child_id) = (parent.id(), child.id());
9512        assert_ne!(parent_id, child_id);
9513
9514        // Serialize via to_writer, NOT to_binary: to_binary re-sorts
9515        // parents-first (subject last), which is precisely why bsv_rs's own
9516        // output never triggers the bug. The corrupt rows held WALLET
9517        // serialized bytes stored verbatim at admit time, whose wire order is
9518        // whatever the wallet emitted — model that by writing the child first.
9519        let mut beef = Beef::new();
9520        beef.merge_transaction(child); // subject FIRST — the trigger
9521        beef.merge_transaction(parent);
9522        let mut w = bsv_rs::primitives::encoding::Writer::new();
9523        beef.to_writer(&mut w);
9524        let bytes = w.into_bytes();
9525
9526        // The premise the whole test rests on: wire order preserved, subject
9527        // genuinely not last.
9528        let reread = Beef::from_binary(&bytes).unwrap();
9529        assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
9530        (bytes, child_id, parent_id)
9531    }
9532
9533    #[test]
9534    fn stitch_preserves_the_subject_when_it_is_not_last() {
9535        use bsv_rs::transaction::{Beef, MerklePath};
9536        let (stored, child_id, parent_id) = subject_not_last_beef();
9537        let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
9538
9539        let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
9540            .expect("stitch must succeed on a beef that contains the txid");
9541
9542        let after = Beef::from_binary(&out).unwrap();
9543        assert!(
9544            after.find_txid(&child_id).is_some(),
9545            "the SUBJECT must survive its own proof-completion"
9546        );
9547        assert!(
9548            after.find_txid(&parent_id).is_some(),
9549            "the ancestry must survive too"
9550        );
9551        assert!(
9552            after.find_bump(&child_id).is_some(),
9553            "the stitched bump must prove the subject"
9554        );
9555        assert!(
9556            after.find_bump(&parent_id).is_none(),
9557            "the parent must NOT be claimed by the child's bump"
9558        );
9559    }
9560
9561    /// The old path, reproduced against the same input, to pin WHY the new one
9562    /// exists: it demonstrably drops the subject. If bsv_rs ever changes
9563    /// `from_beef(_, None)` to honor the subject, this starts failing and the
9564    /// comment trail can be revisited — until then it documents the hazard.
9565    #[test]
9566    fn the_old_round_trip_demonstrably_dropped_the_subject() {
9567        use bsv_rs::transaction::{Beef, Transaction};
9568        let (stored, child_id, _parent_id) = subject_not_last_beef();
9569        let tx = Transaction::from_beef(&stored, None).unwrap();
9570        assert_ne!(
9571            tx.id(),
9572            child_id,
9573            "from_beef(None) picks txs.last(), not the subject — the trigger"
9574        );
9575        let rebuilt = tx.to_beef(true).unwrap();
9576        let reparsed = Beef::from_binary(&rebuilt).unwrap();
9577        assert!(
9578            reparsed.find_txid(&child_id).is_none(),
9579            "the rebuild loses the subject — exactly the #284 artifact"
9580        );
9581    }
9582
9583    /// bsv-low M19 R2 round 3 (review HIGH-1): a stored BEEF whose SUBJECT
9584    /// sits on a stale (orphan) bump at height H, re-stitched with a
9585    /// same-height DIFFERENT-root proof, must serialize with the subject on
9586    /// the NEW bump and the orphan bump dropped — otherwise the hop leg
9587    /// churns and re-latches the orphan proof forever.
9588    #[test]
9589    fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
9590        use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
9591        // subject spends a real parent; both are raw in the BEEF
9592        let parent = Transaction::from_hex(RAW_PARENT).unwrap();
9593        let parent_id = parent.id();
9594        let subject = child_of(&parent_id, 0);
9595        let subject_id = subject.id();
9596        // the ORPHAN bump: a two-leaf block at H so its root is NOT the txid,
9597        // and it differs from the canonical block's root
9598        let orphan = MerklePath::new_unchecked(
9599            965_771,
9600            vec![vec![
9601                MerklePathLeaf::new_txid(0, subject_id.clone()),
9602                MerklePathLeaf::new(1, "aa".repeat(32)),
9603            ]],
9604        )
9605        .unwrap();
9606        let mut beef = Beef::new();
9607        let bi = beef.merge_bump(orphan.clone());
9608        beef.merge_raw_tx(parent.to_binary(), None);
9609        beef.merge_raw_tx(subject.to_binary(), Some(bi));
9610        let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
9611        let stored = beef.to_binary();
9612        assert_eq!(
9613            Beef::from_binary(&stored)
9614                .unwrap()
9615                .find_bump(&subject_id)
9616                .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
9617            Some(orphan_root.clone())
9618        );
9619        // the canonical proof at the SAME height, a DIFFERENT root
9620        let canonical = MerklePath::new_unchecked(
9621            965_771,
9622            vec![vec![
9623                MerklePathLeaf::new_txid(0, subject_id.clone()),
9624                MerklePathLeaf::new(1, "bb".repeat(32)),
9625            ]],
9626        )
9627        .unwrap();
9628        let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
9629        assert_ne!(
9630            canonical_root, orphan_root,
9631            "the reorg shape: same height, new root"
9632        );
9633        let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
9634        let after = Beef::from_binary(&out).unwrap();
9635        // the subject's OWN bump is the canonical one
9636        let own = after
9637            .find_txid(&subject_id)
9638            .and_then(BeefTx::bump_index)
9639            .and_then(|bi| after.bumps.get(bi))
9640            .unwrap();
9641        assert_eq!(
9642            own.compute_root(Some(&subject_id)).unwrap(),
9643            canonical_root,
9644            "the subject re-anchored to the new bump"
9645        );
9646        // the orphan bump is gone (only the canonical one remains)
9647        assert_eq!(
9648            after.bumps.len(),
9649            1,
9650            "the unreferenced orphan bump was dropped"
9651        );
9652        // and a reader that uses the tx's OWN bump sees canonical, never orphan
9653        assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
9654    }
9655
9656    #[test]
9657    fn stitch_refuses_a_beef_that_lacks_the_txid() {
9658        use bsv_rs::transaction::MerklePath;
9659        let (stored, _c, _p) = subject_not_last_beef();
9660        let foreign = "aa".repeat(32);
9661        let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
9662        assert!(
9663            Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
9664            "no txid in the beef -> no write, never garbage"
9665        );
9666    }
9667
9668    /// A real mainnet raw tx, the unproven parent for the re-anchor pin.
9669    const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
9670
9671    /// A minimal raw tx spending `source:vout` with one OP_TRUE output.
9672    fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
9673        let mut sx = String::from("0100000001");
9674        let mut prev = hex::decode(source_txid).unwrap();
9675        prev.reverse();
9676        sx.push_str(&hex::encode(prev));
9677        sx.push_str(&hex::encode(vout.to_le_bytes()));
9678        sx.push_str("00ffffffff01");
9679        sx.push_str(&hex::encode(1000u64.to_le_bytes()));
9680        sx.push_str("015100000000");
9681        bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
9682    }
9683
9684    /// A minimal valid single-leaf BUMP hex proving `txid` at `height`.
9685    fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
9686        use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
9687        let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
9688        MerklePath::new_unchecked(height, vec![vec![leaf]])
9689            .unwrap()
9690            .to_hex()
9691    }
9692
9693    /// Mock fetcher: returns a configured proof (or none) for any txid, and
9694    /// counts how many times it was asked.
9695    struct StubFetcher {
9696        proof: Option<String>,
9697        calls: Cell<u32>,
9698    }
9699
9700    #[async_trait(?Send)]
9701    impl AncestorFetcher for StubFetcher {
9702        async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
9703            self.calls.set(self.calls.get() + 1);
9704            Ok(FetchedAncestor {
9705                raw_tx: PROOFLESS_RAW_TX.to_string(),
9706                proof: self.proof.clone(),
9707            })
9708        }
9709
9710        /// Models a fetcher whose header source CONFIRMS a stored bump — so the
9711        /// already-structurally-proven re-verify gate (the window-clog path)
9712        /// latches without a re-fetch, exactly as before the #192/#193 hardening.
9713        /// (`verified_proof_for` is left at its default: it delegates to
9714        /// `fetch_ancestor` above, so the `calls` counter still tracks fetches.)
9715        async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
9716            true
9717        }
9718    }
9719
9720    /// Build an engine over a storage holding one proofless output, with an
9721    /// optional ancestor fetcher.
9722    async fn engine_with_proofless_output(
9723        fetcher: Option<std::rc::Rc<StubFetcher>>,
9724    ) -> (Engine, String) {
9725        let (beef, txid) = proofless_beef();
9726        let storage = MemoryStorage::new();
9727        storage
9728            .insert_output(&Output {
9729                txid: txid.clone(),
9730                output_index: 0,
9731                output_script: vec![0x76],
9732                satoshis: 1_000_000_000,
9733                topic: "Hello".to_string(),
9734                spent: false,
9735                outputs_consumed: vec![],
9736                consumed_by: vec![],
9737                beef: Some(beef),
9738                block_height: None,
9739                score: Some(1.0),
9740            })
9741            .await
9742            .unwrap();
9743
9744        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9745        managers.insert(
9746            "Hello".into(),
9747            Box::new(MockTopicManager::admitting(vec![0])),
9748        );
9749        let mut engine = Engine::new(
9750            managers,
9751            HashMap::new(),
9752            Box::new(storage),
9753            None,
9754            EngineConfig::default(),
9755        );
9756        if let Some(f) = fetcher {
9757            engine.set_ancestor_fetcher(f);
9758        }
9759        (engine, txid)
9760    }
9761
9762    #[tokio::test]
9763    async fn complete_missing_proofs_noop_without_fetcher() {
9764        // No ancestor fetcher configured → pure no-op (production default).
9765        let (engine, _txid) = engine_with_proofless_output(None).await;
9766        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
9767        assert_eq!(summary, ProofCompletionSummary::default());
9768    }
9769
9770    #[tokio::test]
9771    async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
9772        let (_beef, txid) = proofless_beef();
9773        let proof_hex = single_leaf_bump_hex(&txid, 850_000);
9774        let fetcher = std::rc::Rc::new(StubFetcher {
9775            proof: Some(proof_hex),
9776            calls: Cell::new(0),
9777        });
9778        let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
9779
9780        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
9781        assert_eq!(summary.scanned, 1);
9782        assert_eq!(summary.proofless, 1);
9783        assert_eq!(
9784            summary.completed, 1,
9785            "the proofless BEEF should be completed"
9786        );
9787        assert_eq!(fetcher.calls.get(), 1);
9788
9789        // The stored BEEF now carries a proof for the target tx + the output
9790        // got its block height.
9791        let out = engine
9792            .storage()
9793            .find_output(&txid, 0, Some("Hello"), None, true)
9794            .await
9795            .unwrap()
9796            .unwrap();
9797        assert_eq!(out.block_height, Some(850_000));
9798        let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
9799        assert!(
9800            beef.find_txid(&txid)
9801                .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
9802            "completed BEEF must prove the target tx"
9803        );
9804
9805        // A second pass is a no-op: the tx is now proven, so it is not counted
9806        // proofless and the fetcher is not asked again.
9807        let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
9808        assert_eq!(summary2.proofless, 0);
9809        assert_eq!(summary2.completed, 0);
9810        assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
9811    }
9812
9813    #[tokio::test]
9814    async fn complete_missing_proofs_skips_unconfirmed() {
9815        // Fetcher reachable but the tx is unmined (no BUMP) → skipped, not
9816        // errored, and counted still_unconfirmed for retry.
9817        let fetcher = std::rc::Rc::new(StubFetcher {
9818            proof: None,
9819            calls: Cell::new(0),
9820        });
9821        let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
9822        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
9823        assert_eq!(summary.proofless, 1);
9824        assert_eq!(summary.completed, 0);
9825        assert_eq!(summary.still_unconfirmed, 1);
9826        assert_eq!(fetcher.calls.get(), 1);
9827    }
9828
9829    /// Build an engine like [`engine_with_proofless_output`] but keep a shared
9830    /// handle to the `MemoryStorage` (via the `Rc<T: Storage>` delegation) so
9831    /// the test can advance its deterministic clock — the push-primary
9832    /// backstop age-gate tests (#228) need to age rows without sleeping.
9833    async fn engine_with_proofless_output_shared(
9834        fetcher: Option<std::rc::Rc<StubFetcher>>,
9835    ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
9836        let (beef, txid) = proofless_beef();
9837        let storage = std::rc::Rc::new(MemoryStorage::new());
9838        storage
9839            .insert_output(&Output {
9840                txid: txid.clone(),
9841                output_index: 0,
9842                output_script: vec![0x76],
9843                satoshis: 1_000_000_000,
9844                topic: "Hello".to_string(),
9845                spent: false,
9846                outputs_consumed: vec![],
9847                consumed_by: vec![],
9848                beef: Some(beef),
9849                block_height: None,
9850                score: Some(1.0),
9851            })
9852            .await
9853            .unwrap();
9854
9855        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9856        managers.insert(
9857            "Hello".into(),
9858            Box::new(MockTopicManager::admitting(vec![0])),
9859        );
9860        let mut engine = Engine::new(
9861            managers,
9862            HashMap::new(),
9863            Box::new(storage.clone()),
9864            None,
9865            EngineConfig::default(),
9866        );
9867        if let Some(f) = fetcher {
9868            engine.set_ancestor_fetcher(f);
9869        }
9870        (engine, txid, storage)
9871    }
9872
9873    #[tokio::test]
9874    async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
9875        // Push-primary backstop (#228): a FRESHLY stored proofless tx must NOT
9876        // be polled — its proof is expected via /arc-ingest at push speed. The
9877        // gated pass scans nothing and never asks the fetcher.
9878        let proof_hex = {
9879            let (_beef, txid) = proofless_beef();
9880            single_leaf_bump_hex(&txid, 850_000)
9881        };
9882        let fetcher = std::rc::Rc::new(StubFetcher {
9883            proof: Some(proof_hex),
9884            calls: Cell::new(0),
9885        });
9886        let (engine, _txid, storage) =
9887            engine_with_proofless_output_shared(Some(fetcher.clone())).await;
9888
9889        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
9890        assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
9891        assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
9892
9893        // WEBHOOK-OUTAGE DEGRADATION: no push ever arrives. Once the row is
9894        // older than the gate, the SAME backstop pass polls and completes it
9895        // exactly as the pre-#228 behaviour — degradation is to polling,
9896        // never to nothing.
9897        storage.advance_clock(1800);
9898        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
9899        assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
9900        assert_eq!(summary.completed, 1, "the backstop completes the proof");
9901        assert_eq!(fetcher.calls.get(), 1);
9902    }
9903
9904    #[tokio::test]
9905    async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
9906        // Pushed-proof-then-chaser-skips (#228): a proof arriving via the real
9907        // /arc-ingest producer path (`handle_new_merkle_proof` → the
9908        // `update_transaction_beef` stitch, which latches `has_proof`) drops
9909        // the tx out of the poll candidate set entirely — even after the age
9910        // gate opens, the chaser never asks the fetcher about it.
9911        let fetcher = std::rc::Rc::new(StubFetcher {
9912            proof: None,
9913            calls: Cell::new(0),
9914        });
9915        let (engine, txid, storage) =
9916            engine_with_proofless_output_shared(Some(fetcher.clone())).await;
9917
9918        // The push lands (arc-ingest calls exactly this after its own
9919        // chaintracks verify).
9920        let proof_hex = single_leaf_bump_hex(&txid, 850_000);
9921        engine
9922            .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
9923            .await
9924            .unwrap();
9925
9926        // Age the row well past the backstop gate: still skipped — the pushed
9927        // proof, not the age, is what removes it from the chaser's world.
9928        storage.advance_clock(1_000_000);
9929        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
9930        assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
9931        assert_eq!(summary.completed, 0);
9932        assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
9933    }
9934
9935    #[tokio::test]
9936    async fn complete_missing_proofs_respects_limit() {
9937        // Two proofless txs, limit 1 → only one row is scanned this tick.
9938        let (beef1, txid1) = proofless_beef();
9939        // A second distinct proofless tx (different raw tx → different txid).
9940        let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
9941        let (beef2, txid2) = {
9942            use bsv_rs::transaction::{Beef, Transaction};
9943            let tx = Transaction::from_hex(raw2).unwrap();
9944            let id = tx.id();
9945            let mut b = Beef::new();
9946            b.merge_transaction(tx);
9947            (b.to_binary(), id)
9948        };
9949
9950        let storage = MemoryStorage::new();
9951        for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
9952            storage
9953                .insert_output(&Output {
9954                    txid: txid.clone(),
9955                    output_index: 0,
9956                    output_script: vec![0x76],
9957                    satoshis: 1000,
9958                    topic: "Hello".to_string(),
9959                    spent: false,
9960                    outputs_consumed: vec![],
9961                    consumed_by: vec![],
9962                    beef: Some(beef),
9963                    block_height: None,
9964                    score: Some(1.0),
9965                })
9966                .await
9967                .unwrap();
9968        }
9969
9970        let fetcher = std::rc::Rc::new(StubFetcher {
9971            proof: None,
9972            calls: Cell::new(0),
9973        });
9974        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9975        managers.insert(
9976            "Hello".into(),
9977            Box::new(MockTopicManager::admitting(vec![0])),
9978        );
9979        let mut engine = Engine::new(
9980            managers,
9981            HashMap::new(),
9982            Box::new(storage),
9983            None,
9984            EngineConfig::default(),
9985        );
9986        engine.set_ancestor_fetcher(fetcher.clone());
9987
9988        let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
9989        assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
9990    }
9991
9992    #[tokio::test]
9993    async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
9994        // Window-clog regression (#130): migration 0010 defaulted EVERY
9995        // existing `transactions` row to has_proof = 0, including rows whose
9996        // stored BEEF already carries a proof (e.g. GASP-synced txs). The
9997        // candidate query (`WHERE has_proof = 0 LIMIT n`) returns those rows
9998        // every tick; the engine skips them but — before this fix — never
9999        // flipped the flag, so they lingered in the bounded window forever and
10000        // starved genuinely-proofless rows behind them. The fix marks each
10001        // already-proven scanned row so it drops out of the NEXT pass.
10002        use bsv_rs::transaction::{Beef, MerklePath, Transaction};
10003
10004        let storage = MemoryStorage::new();
10005
10006        // Seed several ALREADY-PROVEN rows. They are inserted via
10007        // `insert_output`, which (modeling migration 0010's default) does NOT
10008        // set the proof flag — so despite carrying a valid proof they show up
10009        // as has_proof = 0 candidates: the exact clog.
10010        let proven_raws = [
10011            "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
10012            "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
10013        ];
10014        let mut proven_ids = Vec::new();
10015        for raw in proven_raws {
10016            let mut tx = Transaction::from_hex(raw).unwrap();
10017            let id = tx.id();
10018            tx.merkle_path =
10019                Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
10020            // to_beef(true) carries the merkle proof into the BEEF, so the row
10021            // genuinely IS proven — yet its flag stays 0 (insert path).
10022            let proven_beef = tx.to_beef(true).unwrap();
10023            assert_eq!(
10024                Beef::from_binary(&proven_beef)
10025                    .unwrap()
10026                    .find_txid(&id)
10027                    .map(bsv_rs::transaction::BeefTx::has_proof),
10028                Some(true),
10029                "proven fixture row must carry its proof"
10030            );
10031            proven_ids.push(id.clone());
10032            storage
10033                .insert_output(&Output {
10034                    txid: id,
10035                    output_index: 0,
10036                    output_script: vec![0x76],
10037                    satoshis: 1000,
10038                    topic: "Hello".to_string(),
10039                    spent: false,
10040                    outputs_consumed: vec![],
10041                    consumed_by: vec![],
10042                    beef: Some(proven_beef),
10043                    block_height: Some(800_000),
10044                    score: Some(1.0),
10045                })
10046                .await
10047                .unwrap();
10048        }
10049
10050        let fetcher = std::rc::Rc::new(StubFetcher {
10051            proof: None,
10052            calls: Cell::new(0),
10053        });
10054        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10055        managers.insert(
10056            "Hello".into(),
10057            Box::new(MockTopicManager::admitting(vec![0])),
10058        );
10059        let mut engine = Engine::new(
10060            managers,
10061            HashMap::new(),
10062            Box::new(storage),
10063            None,
10064            EngineConfig::default(),
10065        );
10066        engine.set_ancestor_fetcher(fetcher.clone());
10067
10068        // First pass over a window covering both proven rows: each is detected
10069        // already-proven and marked. Nothing proofless, nothing fetched.
10070        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10071        assert_eq!(summary.scanned, proven_ids.len());
10072        assert_eq!(summary.proofless, 0);
10073        assert_eq!(summary.completed, 0);
10074        assert_eq!(
10075            summary.already_proven,
10076            proven_ids.len(),
10077            "every already-proven row is marked this pass"
10078        );
10079        assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
10080
10081        // Window has advanced: the NEXT candidate query no longer returns the
10082        // marked rows, so the pass scans nothing.
10083        let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
10084        assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
10085        assert_eq!(summary2.already_proven, 0);
10086    }
10087
10088    #[tokio::test]
10089    async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
10090        // The window-clog in action with a small limit: a genuinely-proofless
10091        // backlog row sits behind already-proven (stale-flag) rows. Each tick
10092        // marks the proven rows it scans (dropping them out), so over a few
10093        // bounded ticks the window advances until the proofless tx is reached
10094        // + completed — instead of being starved forever.
10095        use bsv_rs::transaction::{MerklePath, Transaction};
10096
10097        let storage = MemoryStorage::new();
10098
10099        let proven_raws = [
10100            "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
10101        ];
10102        for raw in proven_raws {
10103            let mut tx = Transaction::from_hex(raw).unwrap();
10104            let id = tx.id();
10105            tx.merkle_path =
10106                Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
10107            let proven_beef = tx.to_beef(true).unwrap();
10108            storage
10109                .insert_output(&Output {
10110                    txid: id,
10111                    output_index: 0,
10112                    output_script: vec![0x76],
10113                    satoshis: 1000,
10114                    topic: "Hello".to_string(),
10115                    spent: false,
10116                    outputs_consumed: vec![],
10117                    consumed_by: vec![],
10118                    beef: Some(proven_beef),
10119                    block_height: Some(800_000),
10120                    score: Some(1.0),
10121                })
10122                .await
10123                .unwrap();
10124        }
10125
10126        // The historical proofless tx (the f584f846-style backlog row).
10127        let (proofless, proofless_txid) = proofless_beef();
10128        storage
10129            .insert_output(&Output {
10130                txid: proofless_txid.clone(),
10131                output_index: 0,
10132                output_script: vec![0x76],
10133                satoshis: 1000,
10134                topic: "Hello".to_string(),
10135                spent: false,
10136                outputs_consumed: vec![],
10137                consumed_by: vec![],
10138                beef: Some(proofless),
10139                block_height: None,
10140                score: Some(1.0),
10141            })
10142            .await
10143            .unwrap();
10144
10145        let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
10146        let fetcher = std::rc::Rc::new(StubFetcher {
10147            proof: Some(proof_hex),
10148            calls: Cell::new(0),
10149        });
10150        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10151        managers.insert(
10152            "Hello".into(),
10153            Box::new(MockTopicManager::admitting(vec![0])),
10154        );
10155        let mut engine = Engine::new(
10156            managers,
10157            HashMap::new(),
10158            Box::new(storage),
10159            None,
10160            EngineConfig::default(),
10161        );
10162        engine.set_ancestor_fetcher(fetcher.clone());
10163
10164        // Run bounded ticks (limit 1) until the proofless tx is completed. With
10165        // 2 rows total a couple of ticks suffice; cap iterations defensively.
10166        let mut completed_total = 0;
10167        for _ in 0..5 {
10168            let s = engine.complete_missing_proofs(1, 0).await.unwrap();
10169            completed_total += s.completed;
10170            if completed_total > 0 {
10171                break;
10172            }
10173        }
10174        assert_eq!(
10175            completed_total, 1,
10176            "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
10177        );
10178
10179        let out = engine
10180            .storage()
10181            .find_output(&proofless_txid, 0, Some("Hello"), None, true)
10182            .await
10183            .unwrap()
10184            .unwrap();
10185        let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
10186        assert_eq!(
10187            beef.find_txid(&proofless_txid)
10188                .map(bsv_rs::transaction::BeefTx::has_proof),
10189            Some(true),
10190            "the old proofless tx now carries its proof"
10191        );
10192    }
10193
10194    // ── S2 queue-durable admission (bsv-low 2026-08-29): Phase-3 faults ──
10195    //
10196    // The phantom class: under a D1 storm `submit` returned Ok(steak) while
10197    // its writes failed — the ack outlived the admission. These pins hold
10198    // the new contract: a fault is REPORTED, the faulted topic is NOT
10199    // recorded as applied, and a replay of the same bytes re-applies.
10200
10201    use crate::storage::StorageError;
10202    use std::rc::Rc;
10203
10204    /// `MemoryStorage` behind two fault switches. Every method delegates;
10205    /// the two the tests flip model a D1 write fault (`insert_output`) and
10206    /// a D1 read fault (`find_output` — the validation's previous-coin scan
10207    /// AND Phase 3's stale/consumed lookups).
10208    struct FaultingStorage {
10209        inner: MemoryStorage,
10210        fail_insert_output: Cell<bool>,
10211        fail_find_output: Cell<bool>,
10212    }
10213
10214    impl FaultingStorage {
10215        fn new() -> Rc<Self> {
10216            Rc::new(Self {
10217                inner: MemoryStorage::new(),
10218                fail_insert_output: Cell::new(false),
10219                fail_find_output: Cell::new(false),
10220            })
10221        }
10222    }
10223
10224    #[async_trait(?Send)]
10225    impl Storage for FaultingStorage {
10226        async fn put_deferred_graph(
10227            &self,
10228            record: &crate::gasp::DeferredGraph,
10229        ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
10230            self.inner.put_deferred_graph(record).await
10231        }
10232        async fn find_deferred_graphs(
10233            &self,
10234            host: &str,
10235            topic: &str,
10236        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
10237            self.inner.find_deferred_graphs(host, topic).await
10238        }
10239        async fn get_deferred_graph(
10240            &self,
10241            host: &str,
10242            topic: &str,
10243            outpoint: &str,
10244        ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
10245            self.inner.get_deferred_graph(host, topic, outpoint).await
10246        }
10247        async fn delete_deferred_graph(
10248            &self,
10249            host: &str,
10250            topic: &str,
10251            outpoint: &str,
10252        ) -> Result<(), StorageError> {
10253            self.inner
10254                .delete_deferred_graph(host, topic, outpoint)
10255                .await
10256        }
10257        async fn record_peer_sync_outcome(
10258            &self,
10259            host: &str,
10260            topic: &str,
10261            success: bool,
10262        ) -> Result<(), StorageError> {
10263            self.inner
10264                .record_peer_sync_outcome(host, topic, success)
10265                .await
10266        }
10267        async fn get_peer_sync_health(
10268            &self,
10269            host: &str,
10270            topic: &str,
10271        ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
10272            self.inner.get_peer_sync_health(host, topic).await
10273        }
10274        async fn record_peer_sync_yield(
10275            &self,
10276            host: &str,
10277            topic: &str,
10278            yielded: bool,
10279        ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
10280            self.inner
10281                .record_peer_sync_yield(host, topic, yielded)
10282                .await
10283        }
10284        async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
10285            if self.fail_insert_output.get() {
10286                return Err(StorageError::Database(
10287                    "D1_ERROR: storage overloaded".into(),
10288                ));
10289            }
10290            self.inner.insert_output(output).await
10291        }
10292        async fn delete_output(
10293            &self,
10294            txid: &str,
10295            output_index: u32,
10296            topic: &str,
10297        ) -> Result<(), StorageError> {
10298            self.inner.delete_output(txid, output_index, topic).await
10299        }
10300        async fn mark_utxo_as_spent(
10301            &self,
10302            txid: &str,
10303            output_index: u32,
10304            topic: &str,
10305        ) -> Result<(), StorageError> {
10306            self.inner
10307                .mark_utxo_as_spent(txid, output_index, topic)
10308                .await
10309        }
10310        async fn update_consumed_by(
10311            &self,
10312            txid: &str,
10313            output_index: u32,
10314            topic: &str,
10315            consumed_by: &[Outpoint],
10316        ) -> Result<(), StorageError> {
10317            self.inner
10318                .update_consumed_by(txid, output_index, topic, consumed_by)
10319                .await
10320        }
10321        async fn update_transaction_beef(
10322            &self,
10323            txid: &str,
10324            beef: &[u8],
10325        ) -> Result<(), StorageError> {
10326            self.inner.update_transaction_beef(txid, beef).await
10327        }
10328        async fn insert_applied_transaction(
10329            &self,
10330            tx: &AppliedTransaction,
10331        ) -> Result<(), StorageError> {
10332            self.inner.insert_applied_transaction(tx).await
10333        }
10334        async fn does_applied_transaction_exist(
10335            &self,
10336            tx: &AppliedTransaction,
10337        ) -> Result<bool, StorageError> {
10338            self.inner.does_applied_transaction_exist(tx).await
10339        }
10340        async fn delete_applied_transaction(
10341            &self,
10342            tx: &AppliedTransaction,
10343        ) -> Result<(), StorageError> {
10344            self.inner.delete_applied_transaction(tx).await
10345        }
10346        async fn find_output(
10347            &self,
10348            txid: &str,
10349            output_index: u32,
10350            topic: Option<&str>,
10351            spent: Option<bool>,
10352            include_beef: bool,
10353        ) -> Result<Option<Output>, StorageError> {
10354            if self.fail_find_output.get() {
10355                return Err(StorageError::Database("D1_ERROR: read timed out".into()));
10356            }
10357            self.inner
10358                .find_output(txid, output_index, topic, spent, include_beef)
10359                .await
10360        }
10361        async fn find_outputs_for_transaction(
10362            &self,
10363            txid: &str,
10364            include_beef: bool,
10365        ) -> Result<Vec<Output>, StorageError> {
10366            self.inner
10367                .find_outputs_for_transaction(txid, include_beef)
10368                .await
10369        }
10370        async fn find_utxos_for_topic(
10371            &self,
10372            topic: &str,
10373            since: Option<f64>,
10374            limit: Option<u64>,
10375            include_beef: bool,
10376        ) -> Result<Vec<Output>, StorageError> {
10377            self.inner
10378                .find_utxos_for_topic(topic, since, limit, include_beef)
10379                .await
10380        }
10381        async fn update_last_interaction(
10382            &self,
10383            host: &str,
10384            topic: &str,
10385            since: u64,
10386        ) -> Result<(), StorageError> {
10387            self.inner.update_last_interaction(host, topic, since).await
10388        }
10389        async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
10390            self.inner.get_last_interaction(host, topic).await
10391        }
10392    }
10393
10394    fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
10395        let storage = FaultingStorage::new();
10396        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
10397        managers.insert(
10398            "tm_test".to_string(),
10399            Box::new(MockTopicManager::admitting(admit_indices)),
10400        );
10401        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
10402        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
10403        let engine = Engine::new(
10404            managers,
10405            lookup_services,
10406            Box::new(Rc::clone(&storage)),
10407            None,
10408            EngineConfig::default(),
10409        );
10410        (engine, storage)
10411    }
10412
10413    async fn applied(storage: &FaultingStorage) -> bool {
10414        storage
10415            .does_applied_transaction_exist(&AppliedTransaction {
10416                txid: TEST_TXID.to_string(),
10417                topic: "tm_test".to_string(),
10418            })
10419            .await
10420            .unwrap()
10421    }
10422
10423    /// The old behaviour, pinned: a clean submit is durable, records the
10424    /// topic as applied, and the output is in storage.
10425    #[tokio::test]
10426    async fn clean_submit_is_durable_and_records_applied() {
10427        let (engine, storage) = make_faulting_engine(vec![0]);
10428        let (steak, report) = engine
10429            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10430            .await
10431            .unwrap();
10432        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
10433        assert!(report.is_durable(), "{}", report.summary());
10434        assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
10435        assert!(applied(&storage).await);
10436        assert!(storage
10437            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
10438            .await
10439            .unwrap()
10440            .is_some());
10441    }
10442
10443    /// THE PHANTOM, red→green: the insert fails, the steak still says
10444    /// "admitted" (the decision stands), but the report says NOT DURABLE and
10445    /// names the site — and the topic is NOT recorded as applied, so nothing
10446    /// downstream can treat the loss as final.
10447    #[tokio::test]
10448    async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
10449        let (engine, storage) = make_faulting_engine(vec![0]);
10450        storage.fail_insert_output.set(true);
10451        let (steak, report) = engine
10452            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10453            .await
10454            .unwrap();
10455        assert_eq!(
10456            steak["tm_test"].outputs_to_admit,
10457            vec![0],
10458            "the admission DECISION is unchanged by a write fault"
10459        );
10460        assert!(!report.is_durable());
10461        assert_eq!(report.faults.len(), 1, "{}", report.summary());
10462        assert_eq!(report.faults[0].topic, "tm_test");
10463        assert_eq!(report.faults[0].site, "insert_output");
10464        assert!(report.faults[0].error.contains("D1_ERROR"));
10465        assert!(report.applied_topics.is_empty());
10466        assert!(
10467            !applied(&storage).await,
10468            "a faulted topic must not be recorded as applied — the replay would be a dupe"
10469        );
10470        assert!(storage
10471            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
10472            .await
10473            .unwrap()
10474            .is_none());
10475    }
10476
10477    /// The replay: same bytes, storage healthy again → re-validated (not a
10478    /// dupe), re-written, recorded. A THIRD submit is then the dupe it
10479    /// should be: nothing admitted, nothing to write, durable.
10480    #[tokio::test]
10481    async fn replay_after_a_fault_reapplies_and_records_applied() {
10482        let (engine, storage) = make_faulting_engine(vec![0]);
10483        storage.fail_insert_output.set(true);
10484        let (_, first) = engine
10485            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10486            .await
10487            .unwrap();
10488        assert!(!first.is_durable());
10489
10490        storage.fail_insert_output.set(false);
10491        let (steak, replay) = engine
10492            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10493            .await
10494            .unwrap();
10495        assert_eq!(
10496            steak["tm_test"].outputs_to_admit,
10497            vec![0],
10498            "re-validated, not deduped"
10499        );
10500        assert!(replay.is_durable(), "{}", replay.summary());
10501        assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
10502        assert!(applied(&storage).await);
10503        assert!(storage
10504            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
10505            .await
10506            .unwrap()
10507            .is_some());
10508
10509        let (steak, third) = engine
10510            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10511            .await
10512            .unwrap();
10513        assert!(
10514            steak["tm_test"].outputs_to_admit.is_empty(),
10515            "now a genuine dupe: nothing admitted"
10516        );
10517        assert!(third.is_durable() && third.applied_topics.is_empty());
10518    }
10519
10520    /// The SPEND half of the phantom: a `find_output` fault during
10521    /// validation made a settle look like it consumed nothing, so the spend
10522    /// pointer was never written and the ack stood. Now the read fault is
10523    /// reported, the topic stays unrecorded, the previously-admitted coin
10524    /// is untouched — and the healthy replay consumes it.
10525    #[tokio::test]
10526    async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
10527        let (engine, storage) = make_faulting_engine(vec![0]);
10528        // The subject's own input: seed the coin it spends as a previously
10529        // admitted tm_test output.
10530        let tx = Transaction::from_beef(&test_beef(), None).unwrap();
10531        let source_txid = tx.inputs[0].get_source_txid().unwrap();
10532        let source_vout = tx.inputs[0].source_output_index;
10533        storage
10534            .insert_output(&Output {
10535                txid: source_txid.clone(),
10536                output_index: source_vout,
10537                output_script: vec![0x51],
10538                satoshis: 1,
10539                topic: "tm_test".to_string(),
10540                spent: false,
10541                outputs_consumed: vec![],
10542                consumed_by: vec![],
10543                beef: None,
10544                block_height: None,
10545                score: Some(1.0),
10546            })
10547            .await
10548            .unwrap();
10549
10550        storage.fail_find_output.set(true);
10551        let (_, faulted) = engine
10552            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10553            .await
10554            .unwrap();
10555        assert!(!faulted.is_durable());
10556        assert!(
10557            faulted.faults.iter().any(|f| f.site == "find_output"),
10558            "{}",
10559            faulted.summary()
10560        );
10561        assert!(
10562            !applied(&storage).await,
10563            "unrecorded: the replay must re-read"
10564        );
10565        storage.fail_find_output.set(false);
10566        assert!(
10567            storage
10568                .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
10569                .await
10570                .unwrap()
10571                .is_some(),
10572            "the coin the faulted pass could not see is still there, untouched"
10573        );
10574
10575        let (_, replay) = engine
10576            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10577            .await
10578            .unwrap();
10579        assert!(replay.is_durable(), "{}", replay.summary());
10580        assert!(applied(&storage).await);
10581        // The mock manager retains no coins, so the consumed coin is stale
10582        // and deleted — the observable proof the spend was PROCESSED.
10583        assert!(
10584            storage
10585                .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
10586                .await
10587                .unwrap()
10588                .is_none(),
10589            "the healthy replay consumed the coin the faulted pass missed"
10590        );
10591    }
10592
10593    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): a lookup service that missed
10594    /// its admit notification is told AGAIN from the engine's own stored
10595    /// outputs — one call per admitted output per service, the WholeTx body
10596    /// naming the subject — while `/submit` of the same bytes is deduped and
10597    /// tells nobody (the premise, RED-verified in the same test); a topic the
10598    /// engine never admitted this txid on yields 0 (the caller re-submits).
10599    #[tokio::test]
10600    async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
10601        use std::cell::RefCell;
10602        use std::rc::Rc;
10603
10604        struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
10605        #[async_trait(?Send)]
10606        impl LookupServiceTrait for CountingLs {
10607            fn admission_mode(&self) -> AdmissionMode {
10608                self.1
10609            }
10610            fn spend_notification_mode(&self) -> SpendNotificationMode {
10611                SpendNotificationMode::None
10612            }
10613            async fn output_admitted_by_topic(
10614                &self,
10615                payload: &OutputAdmittedByTopic,
10616            ) -> Result<(), LookupServiceError> {
10617                let rec = match payload {
10618                    OutputAdmittedByTopic::LockingScript {
10619                        txid,
10620                        output_index,
10621                        topic,
10622                        ..
10623                    } => (txid.clone(), *output_index, topic.clone()),
10624                    OutputAdmittedByTopic::WholeTx {
10625                        atomic_beef,
10626                        output_index,
10627                        topic,
10628                        ..
10629                    } => {
10630                        let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
10631                        (
10632                            beef.atomic_txid
10633                                .clone()
10634                                .expect("the WholeTx body NAMES its subject"),
10635                            *output_index,
10636                            topic.clone(),
10637                        )
10638                    }
10639                };
10640                self.0.borrow_mut().push(rec);
10641                Ok(())
10642            }
10643            async fn output_evicted(
10644                &self,
10645                _txid: &str,
10646                _output_index: u32,
10647            ) -> Result<(), LookupServiceError> {
10648                Ok(())
10649            }
10650            async fn lookup(
10651                &self,
10652                _question: &LookupQuestion,
10653            ) -> Result<LookupResult, LookupServiceError> {
10654                Ok(LookupResult::OutputList(Vec::new()))
10655            }
10656            async fn get_documentation(&self) -> String {
10657                "counting".to_string()
10658            }
10659            async fn get_metadata(&self) -> ServiceMetadata {
10660                ServiceMetadata {
10661                    name: "counting-ls".to_string(),
10662                    ..Default::default()
10663                }
10664            }
10665        }
10666
10667        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
10668        managers.insert(
10669            "tm_test".to_string(),
10670            Box::new(MockTopicManager::admitting(vec![0])),
10671        );
10672        let seen = Rc::new(RefCell::new(Vec::new()));
10673        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
10674        lookup_services.insert(
10675            "ls_whole".to_string(),
10676            Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
10677        );
10678        lookup_services.insert(
10679            "ls_script".to_string(),
10680            Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
10681        );
10682        let engine = Engine::new(
10683            managers,
10684            lookup_services,
10685            Box::new(MemoryStorage::new()),
10686            None,
10687            EngineConfig::default(),
10688        );
10689
10690        let tagged = test_tagged_beef(vec!["tm_test"]);
10691        engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
10692        assert_eq!(seen.borrow().len(), 2, "one admit per service");
10693        let (txid, vout, _) = seen.borrow()[0].clone();
10694        assert_eq!(txid, TEST_TXID);
10695
10696        // the premise, RED-verified: the same bytes again are DEDUPED — /submit can never re-notify
10697        let (_, report) = engine
10698            .submit_with_report(&tagged, SubmitMode::CurrentTx)
10699            .await
10700            .unwrap();
10701        assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
10702        assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
10703
10704        // the verb: told again from the stored outputs, the WholeTx body naming the subject
10705        let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
10706        assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
10707        assert_eq!(r.vouts, vec![vout]);
10708        assert_eq!(seen.borrow().len(), 4);
10709        assert!(seen.borrow()[2..]
10710            .iter()
10711            .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
10712
10713        // a topic the engine never admitted this txid on: nothing to re-notify (re-submit instead)
10714        let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
10715        assert_eq!((r0.outputs, r0.notified), (0, 0));
10716        assert_eq!(seen.borrow().len(), 4);
10717
10718        // idempotent: a second re-notify tells everyone again, faults nothing
10719        let r2 = engine
10720            .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
10721            .await
10722            .unwrap();
10723        assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
10724        assert_eq!(seen.borrow().len(), 6);
10725    }
10726
10727    /// bsv-low PLAN-PRE-LOOP4 §H4: a PHANTOM applied row (the topic admitted
10728    /// NOTHING) dedups every re-submit of those bytes; forgetting it lets the
10729    /// same bytes be judged again — while a REAL admission's row is never
10730    /// forgotten and a missing row is nothing to forget.
10731    #[tokio::test]
10732    async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
10733        // phantom: a TM that admits nothing still records the topic as applied
10734        let engine = make_engine(vec![]);
10735        let tagged = test_tagged_beef(vec!["tm_test"]);
10736        let (steak, r1) = engine
10737            .submit_with_report(&tagged, SubmitMode::CurrentTx)
10738            .await
10739            .unwrap();
10740        assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
10741        assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
10742        let (_, r2) = engine
10743            .submit_with_report(&tagged, SubmitMode::CurrentTx)
10744            .await
10745            .unwrap();
10746        assert_eq!(
10747            r2.deduped_topics,
10748            vec!["tm_test".to_string()],
10749            "the phantom row dedups the re-submit"
10750        );
10751        assert!(engine
10752            .forget_phantom_applied(TEST_TXID, "tm_test")
10753            .await
10754            .unwrap());
10755        let (_, r3) = engine
10756            .submit_with_report(&tagged, SubmitMode::CurrentTx)
10757            .await
10758            .unwrap();
10759        assert!(
10760            r3.deduped_topics.is_empty(),
10761            "judged again after the forget"
10762        );
10763        assert!(
10764            !engine
10765                .forget_phantom_applied(TEST_TXID, "tm_other")
10766                .await
10767                .unwrap(),
10768            "no row: nothing to forget"
10769        );
10770
10771        // real: a TM that admits output 0 — its row is a real admission, never forgotten
10772        let real = make_engine(vec![0]);
10773        real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
10774        assert!(!real
10775            .forget_phantom_applied(TEST_TXID, "tm_test")
10776            .await
10777            .unwrap());
10778        let (_, r4) = real
10779            .submit_with_report(&tagged, SubmitMode::CurrentTx)
10780            .await
10781            .unwrap();
10782        assert_eq!(
10783            r4.deduped_topics,
10784            vec!["tm_test".to_string()],
10785            "a real admission still dedups"
10786        );
10787    }
10788
10789    // ── NL-6e: the SPV walk is linear in a BUMP's leaves (NL-8 W1) ─────
10790
10791    /// Named and generous (a debug build on a loaded machine): the walk at
10792    /// bsv-rs 0.4.0 took 30 to 36 s at 4,096 leaves in release and gave no
10793    /// answer in 120 s at 8,192 (NL-8 W1).
10794    const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
10795
10796    /// The wide BUMP's height (NL-8's `wide k` row).
10797    const WIDE_HEIGHT: u32 = 800_001;
10798
10799    /// `2^k` txids, display hex: the double SHA-256 of `i` (NL-8's probe).
10800    fn wide_leaves(k: u32) -> Vec<String> {
10801        (0..1u32 << k)
10802            .map(|i| {
10803                let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
10804                id.reverse();
10805                hex::encode(id)
10806            })
10807            .collect()
10808    }
10809
10810    /// One BUMP at [`WIDE_HEIGHT`] whose level 0 carries `txids` as hashed
10811    /// leaves and whose `k - 1` upper levels are empty: every node above
10812    /// level 0 is computed from it. Its root, by one walk.
10813    fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
10814        let k = txids.len().trailing_zeros() as usize;
10815        let mut levels = vec![Vec::new(); k];
10816        levels[0] = txids
10817            .iter()
10818            .enumerate()
10819            .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
10820            .collect();
10821        let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
10822        let root = bump.compute_root(None).unwrap();
10823        (bump, root)
10824    }
10825
10826    /// NL-8 W1's bytes: a V1 BEEF of the one wide BUMP and no transaction
10827    /// (`wide_14` at `2^k` leaves, byte for byte the runner's probe).
10828    fn w1_bytes(k: u32) -> (Vec<u8>, String) {
10829        let (bump, root) = wide_bump(&wide_leaves(k));
10830        let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
10831        beef.extend_from_slice(&bump.to_binary());
10832        beef.push(0x00);
10833        (beef, root)
10834    }
10835
10836    /// The entry, [`verify_spv_like_the_reference`], on its own thread under
10837    /// a tracker that carries `root` at [`WIDE_HEIGHT`]: its answer and its
10838    /// time, or a panic naming the bound.
10839    fn walk_within_bound(
10840        bytes: Vec<u8>,
10841        root: String,
10842        verify_scripts: bool,
10843        subject: String,
10844        what: &str,
10845    ) -> (Result<(), String>, std::time::Duration) {
10846        let (tx, rx) = std::sync::mpsc::channel();
10847        std::thread::spawn(move || {
10848            let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
10849            tracker.add_root(WIDE_HEIGHT, root);
10850            let runtime = tokio::runtime::Builder::new_current_thread()
10851                .build()
10852                .unwrap();
10853            let started = std::time::Instant::now();
10854            let verdict = runtime.block_on(verify_spv_like_the_reference(
10855                Some(&tracker),
10856                verify_scripts,
10857                &bytes,
10858                &subject,
10859                DoorBudget::DEFAULT,
10860            ));
10861            let verdict = match verdict {
10862                Ok(None) => Ok(()),
10863                Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
10864                Err(e) => Err(e.to_string()),
10865            };
10866            let _ = tx.send((verdict, started.elapsed()));
10867        });
10868        rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
10869            panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
10870        })
10871    }
10872
10873    /// The escape hatch's structural walk (`verify_scripts` false) on NL-8
10874    /// W1's bytes, 4,096 and 8,192 leaves: accepted, the one root asked of
10875    /// the tracker, well inside the bound.
10876    #[test]
10877    fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
10878        for k in [12u32, 13] {
10879            let (bytes, root) = w1_bytes(k);
10880            let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
10881            let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
10882            println!(
10883                "NL-6e W1 {what}: {verdict:?} in {:.3} s",
10884                took.as_secs_f64()
10885            );
10886            assert_eq!(verdict, Ok(()), "{what}");
10887        }
10888    }
10889
10890    /// The default walk (scripts run, `verify_scripts` true) over `2^k`
10891    /// proven parents in the one wide BUMP, each spent by a binary tree of
10892    /// two-input transactions whose root is the subject: every parent's
10893    /// root is the BUMP's, asked of the tracker, the inputs `2^(k+1) - 2`.
10894    fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
10895        use bsv_rs::script::{LockingScript, UnlockingScript};
10896        use bsv_rs::transaction::{TransactionInput, TransactionOutput};
10897        let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
10898        let spending = |txid: &str| {
10899            let mut input = TransactionInput::new(txid.to_string(), 0);
10900            input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
10901            input
10902        };
10903        let parents: Vec<Transaction> = (0..1u32 << k)
10904            .map(|i| {
10905                let mut tx = Transaction::new();
10906                // A coinbase's null outpoint, a distinct index each.
10907                let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
10908                input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
10909                tx.inputs.push(input);
10910                tx.outputs.push(TransactionOutput::new(1, op_true()));
10911                tx
10912            })
10913            .collect();
10914        let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
10915        let (bump, root) = wide_bump(&ids);
10916        let mut beef = Beef::new();
10917        let at = beef.merge_bump(bump);
10918        for parent in &parents {
10919            beef.merge_raw_tx(parent.to_binary(), Some(at));
10920        }
10921        let mut layer = ids;
10922        while layer.len() > 1 {
10923            layer = layer
10924                .chunks(2)
10925                .map(|pair| {
10926                    let mut tx = Transaction::new();
10927                    tx.inputs.push(spending(&pair[0]));
10928                    tx.inputs.push(spending(&pair[1]));
10929                    tx.outputs.push(TransactionOutput::new(0, op_true()));
10930                    beef.merge_raw_tx(tx.to_binary(), None);
10931                    tx.id()
10932                })
10933                .collect();
10934        }
10935        (beef.to_binary(), root, layer.remove(0))
10936    }
10937
10938    #[test]
10939    fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
10940        for k in [12u32, 13] {
10941            let (bytes, root, subject) = proven_fan_in(k);
10942            let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
10943            let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
10944            println!(
10945                "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
10946                took.as_secs_f64()
10947            );
10948            assert_eq!(verdict, Ok(()), "{what}");
10949        }
10950    }
10951}