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    /// OPT-IN / OFF BY DEFAULT chain-backed ancestor fetcher for GASP ingest.
73    /// When `None` (default / production), GASP ingest is byte-identical to
74    /// today. A platform crate may set `Some` (one-time-migration only) to let
75    /// ingest self-heal missing ancestry by fetching it from chain.
76    ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
77    /// bsv-low#302: per-peer GASP sync wall-clock budget —
78    /// `(sleep factory, budget ms)`. `None` (default) = unbounded per-peer
79    /// sync, byte-identical to the pre-#302 behavior. When set, each peer's
80    /// `GASPSync::sync` is raced against `sleep(budget_ms)`; a peer that
81    /// exceeds the budget is DROPPED (loud log, failure recorded, cursor NOT
82    /// advanced) and the loop continues with the next peer.
83    peer_sync_budget: Option<(SleepFactory, u64)>,
84    /// The per-GRAPH GASP budget (bsv-low #555): the sleep, the calls, the
85    /// ms. `None`: no graph is deferred.
86    graph_budget: Option<(SleepFactory, u32, u64)>,
87    /// The per-graph budget's bytes and nodes limbs (bsv-low #586,
88    /// [`Engine::set_graph_budget_limbs`]): in force once a graph budget is
89    /// set.
90    graph_budget_limbs: (u64, u32),
91    /// The bound of one transaction's finalize submit (bsv-low #559), see
92    /// [`Engine::set_finalize_submit_budget`].
93    finalize_submit_budget: Option<(SleepFactory, u64)>,
94    /// Open around each GASP finalize submit, so no deadline that races the
95    /// sync drops a transaction between its writes (bsv-low #552, the lens
96    /// fold's HIGH-1). See [`Engine::finalize_submit_gate`].
97    finalize_gate: crate::gasp::SubmitGate,
98    /// (txid, topic) pairs whose Phase-3 writes FAULTED in this engine and
99    /// have not landed since (bsv-low #559): a successor that finds no coin
100    /// while its predecessor is in here is not recorded as applied. One
101    /// invocation's memory; across invocations the same question is put to
102    /// the store ([`Engine::unlanded_predecessor`]).
103    not_landed: std::cell::RefCell<HashSet<(String, String)>>,
104    /// Asked before the door lands a carried predecessor (bsv-low #575, the
105    /// E1D delta-2 fold, L2). See [`Engine::set_landing_guard`].
106    landing_guard: Option<LandingGuard>,
107    /// Reference-parity spend verification on submit (2026-09-08). `true`
108    /// (DEFAULT): every submit outside `HistoricalTxNoSpv` runs the
109    /// reference's `Transaction.verify` walk: merkle paths against the chain
110    /// tracker AND every input's unlocking script EXECUTED, recursively over
111    /// the unproven ancestry in the BEEF; with no chain tracker the walk is
112    /// the ts-sdk's `'scripts only'` (roots accepted unchecked, scripts still
113    /// run). `false` is an ESCAPE HATCH, not a mode: it restores the
114    /// pre-2026-09-08 structural check (`Beef::verify_valid` + every root
115    /// against the tracker; NOTHING without a tracker), which admitted an
116    /// invalid spend on structure alone. See
117    /// [`Engine::set_script_verification`].
118    verify_scripts: bool,
119    /// The two limbs of the submit's own script walk (bsv-low #592): the
120    /// door's [`DoorBudget`], [`DoorBudget::DEFAULT`] unless set. See
121    /// [`Engine::set_walk_budget`].
122    walk_budget: DoorBudget,
123    config: EngineConfig,
124}
125
126/// Boxed platform sleep future (bsv-low#302). Not `Send` — the engine runs
127/// single-threaded on wasm (Workers) and under `#[tokio::test]` locally.
128pub type SleepFuture = std::pin::Pin<Box<dyn std::future::Future<Output = ()>>>;
129
130/// Platform sleep factory (bsv-low#302): milliseconds → a future that
131/// resolves after that delay. The Cloudflare crate passes its `sleep_ms`;
132/// tests pass `ready(())` (instant deadline) or `pending()` (no deadline)
133/// to drive the race deterministically.
134pub type SleepFactory = std::rc::Rc<dyn Fn(u64) -> SleepFuture>;
135
136/// The answer of a [`LandingGuard`]: `Ok` lets the body land, `Err` says why
137/// it may not (the topic waits).
138pub type LandingGuardFuture =
139    std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
140
141/// The caller's admission predicate over a carried predecessor the door is
142/// about to land: `(predecessor txid, topic)`. See
143/// [`Engine::set_landing_guard`].
144pub type LandingGuard = std::rc::Rc<dyn Fn(&str, &str) -> LandingGuardFuture>;
145
146/// The bound of ONE GASP finalize submit's storage calls and hooks (bsv-low
147/// #559, the lens fold of 2026-10-07, F2). The submit itself is never
148/// dropped: it is the write section no deadline drops, and c9921ee, which
149/// raced the whole submit against its budget inside the section, dropped it
150/// between two writes all the same. Each storage call, lookup hook and topic
151/// manager call inside it is raced against the section's ONE deadline
152/// instead, and a call that has not answered when the deadline falls due is
153/// dropped ALONE and reported as that call's fault: the submit runs on to
154/// its end as for any storage fault. Once the deadline is due no further
155/// call is started (each answers "no answer" at once), so a transaction
156/// costs at most its budget, and once more for the undo
157/// ([`CallBound::renew`]).
158struct CallBound {
159    sleep: SleepFactory,
160    budget_ms: u64,
161    deadline: std::cell::RefCell<SleepFuture>,
162    due: std::cell::Cell<bool>,
163}
164
165impl CallBound {
166    fn new(sleep: &SleepFactory, budget_ms: u64) -> Self {
167        Self {
168            deadline: std::cell::RefCell::new(sleep(budget_ms)),
169            sleep: sleep.clone(),
170            budget_ms,
171            due: std::cell::Cell::new(false),
172        }
173    }
174
175    /// A fresh allowance of the same length, for the undo of a faulted
176    /// submit: the calls that put the store back as it was found must not be
177    /// refused because the deadline the fault answered to has passed.
178    fn renew(&self) {
179        *self.deadline.borrow_mut() = (self.sleep)(self.budget_ms);
180        self.due.set(false);
181    }
182
183    /// Whether a call under this bound went unanswered (or was not started
184    /// because one had) since the last [`CallBound::renew`].
185    fn is_due(&self) -> bool {
186        self.due.get()
187    }
188
189    /// `call`, or `None` when the deadline fell due first.
190    async fn call<T>(&self, call: impl std::future::Future<Output = T>) -> Option<T> {
191        if self.due.get() {
192            return None;
193        }
194        let mut call = std::pin::pin!(call);
195        std::future::poll_fn(|cx| {
196            if let std::task::Poll::Ready(v) = call.as_mut().poll(cx) {
197                return std::task::Poll::Ready(Some(v));
198            }
199            if self.deadline.borrow_mut().as_mut().poll(cx).is_ready() {
200                self.due.set(true);
201                return std::task::Poll::Ready(None);
202            }
203            std::task::Poll::Pending
204        })
205        .await
206    }
207}
208
209/// `call` under `bound` (or unbounded with none); `Err` is "no answer".
210async fn bounded<T>(
211    bound: Option<&CallBound>,
212    call: impl std::future::Future<Output = T>,
213) -> Result<T, String> {
214    match bound {
215        Some(bound) => bound
216            .call(call)
217            .await
218            .ok_or_else(|| format!("no answer within {} ms", bound.budget_ms)),
219        None => Ok(call.await),
220    }
221}
222
223/// A storage call under `bound`: no answer in time is that call's fault.
224async fn stored<T>(
225    bound: Option<&CallBound>,
226    call: impl std::future::Future<Output = Result<T, StorageError>>,
227) -> Result<T, StorageError> {
228    match bounded(bound, call).await {
229        Ok(answer) => answer,
230        Err(no_answer) => Err(StorageError::Database(no_answer)),
231    }
232}
233
234/// A lookup hook under `bound`, its error as the report carries it.
235async fn hooked(
236    bound: Option<&CallBound>,
237    call: impl std::future::Future<Output = Result<(), LookupServiceError>>,
238) -> Result<(), String> {
239    bounded(bound, call).await?.map_err(|e| e.to_string())
240}
241
242/// The most store reads [`Engine::unlanded_predecessor`] makes for one
243/// topic of one submit (bsv-low #559, F3): the question is asked of every
244/// transaction that admits nothing and found no coin, so it is kept cheap.
245/// Counted are the reads spent on a body the BEEF does not prove and the
246/// store does not hold as landed (the second delta fold of 2026-10-07, M2);
247/// the 17th of those is never made: out of reads is "not now" (the delta
248/// fold of 2026-10-07, M2). An unproven body still needs room under it for
249/// the reads that find it landed (one for an applied row, two for a held
250/// output): each is tested before it is made.
251const PREDECESSOR_READS: usize = 16;
252
253/// The most store reads [`Engine::unlanded_predecessor`] makes for one
254/// SUBMIT (the third delta fold of 2026-10-07, M1): every read of the
255/// question, counted against [`PREDECESSOR_READS`] or not (those of proven
256/// and landed bodies included), in every topic of the submit together. The
257/// 257th is never made: past the allowance the answer is "not now", never
258/// "landed". Without it the question read as far as the BEEF reached (one
259/// read per 41 bytes: 66,305 reads measured from one 2.7 MB BEEF), on a
260/// public route. Per submit and not per topic because what it protects, the
261/// invocation's D1 allowance, is per invocation; 256 is the door's own unit
262/// ([`DoorBudget`]'s inputs per transaction), no derived figure.
263const PREDECESSOR_READS_PER_SUBMIT: usize = 256;
264
265/// Why a successor must wait ([`Engine::unlanded_predecessor`]), and the
266/// unlanded predecessor whose body the BEEF carries, when the answer names
267/// one: the door lands it first ([`Engine::land_carried`]).
268/// A submit's STEAK and report, and the carried predecessor that blocks each
269/// topic of a carried predecessor's own submit ([`Engine::submit_counted`]).
270type Counted = (Steak, MutationReport, HashMap<String, String>);
271
272/// What the landings of carried predecessors take from their successor's
273/// submit (the E592 lens fold, M1): its breach policy, and whether its own
274/// walk RAN (only then are the bodies it reached trusted).
275#[derive(Debug, Clone, Copy)]
276struct Landing {
277    policy: WalkBreachPolicy,
278    subject_walk_ran: bool,
279}
280
281struct NotNow {
282    why: String,
283    carried: Option<String>,
284}
285
286/// Summary of an [`Engine::complete_missing_proofs`] pass.
287///
288/// All counts are over the single bounded page scanned this tick. A no-fetcher
289/// (production) build returns the all-zero default.
290#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
291pub struct ProofCompletionSummary {
292    /// Stored transactions inspected this tick (the bounded page size).
293    pub scanned: usize,
294    /// Of `scanned`, how many still lacked a merkle proof for their own tx.
295    pub proofless: usize,
296    /// Proofless txs whose BUMP was fetched + stitched into the stored BEEF.
297    pub completed: usize,
298    /// Proofless txs the fetcher could serve but that are not yet mined
299    /// (no BUMP yet) — retried on a later tick.
300    pub still_unconfirmed: usize,
301    /// Proofless txs the fetcher errored on (e.g. budget exhausted, 429) —
302    /// retried on a later tick.
303    pub fetch_failed: usize,
304    /// Proofless txs whose proof came back but failed to stitch into storage.
305    pub stitch_failed: usize,
306    /// Scanned rows that ALREADY carried a valid proof (stale `has_proof = 0`
307    /// flag) and were marked proven this tick so they drop out of the candidate
308    /// window — clearing the window-clog (#130).
309    pub already_proven: usize,
310}
311
312/// Outcome of one [`Engine::sync_advertisements`] run (bsv-low #320 defect
313/// 3a).
314///
315/// The engine used to swallow create/submit failures into `error!` and
316/// return `Ok(())`, so the admin route reported `success` while zero
317/// advertisements were admitted locally; a caller could not tell a
318/// converged no-op (`to_create == 0`) from a silent failure. Every field
319/// here is observational — the sync's behavior is unchanged, its outcome is
320/// no longer hidden.
321#[derive(Debug, Default, Clone, Serialize)]
322#[serde(rename_all = "camelCase")]
323pub struct SyncAdvertisementsReport {
324    /// Advertisements the diff found missing (attempted this run). A
325    /// converged node reports 0 here.
326    pub to_create: usize,
327    /// Stale advertisements the diff said to revoke.
328    pub to_revoke: usize,
329    /// `advertiser.create_advertisements` failure, verbatim.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub create_error: Option<String>,
332    /// Local `Engine::submit` failure for the created ads, verbatim — the
333    /// path that silently hid the self-admission failure live (our own
334    /// ls_ship/ls_slap never gained our ads, so every cycle re-created all
335    /// of them).
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub submit_error: Option<String>,
338    /// Outputs admitted per topic by the local submit (from the STEAK).
339    /// A successful submit that admitted NOTHING (topic-manager refusal)
340    /// shows up here as explicit zeros.
341    pub admitted: std::collections::BTreeMap<String, usize>,
342    /// `advertiser.find_all_advertisements` failure, verbatim (#320 M2).
343    /// When set, creation was REFUSED this run: a blind read must never
344    /// become "current ads = none" and re-create (re-pay) the entire set.
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub lookup_error: Option<String>,
347    /// Stale ads the diff wanted revoked but the advertiser declined to
348    /// build a revocation for (the CF advertiser's documented v1 no-op —
349    /// #320 L2). Distinguishes "revoke happened" from "revoke skipped".
350    pub revoke_skipped: usize,
351    /// `advertiser.revoke_advertisements` failure, verbatim.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub revoke_error: Option<String>,
354    /// Local `Engine::submit` failure for the revocation, verbatim.
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub revoke_submit_error: Option<String>,
357}
358
359impl SyncAdvertisementsReport {
360    /// True when nothing errored. NOTE: a submit that admitted zero outputs
361    /// is not an error at this layer — that refusal is [`Self::effective`]'s
362    /// job.
363    #[must_use]
364    pub fn ok(&self) -> bool {
365        self.create_error.is_none()
366            && self.submit_error.is_none()
367            && self.lookup_error.is_none()
368            && self.revoke_error.is_none()
369            && self.revoke_submit_error.is_none()
370    }
371
372    /// Total outputs admitted by the local submit across topics.
373    #[must_use]
374    pub fn admitted_total(&self) -> usize {
375        self.admitted.values().sum()
376    }
377
378    /// True when the run errored nowhere AND the local submit admitted
379    /// EVERY created ad (#320 M1 + delta D1): each advertisement is exactly
380    /// one output and change is never a SHIP/SLAP token, so full admission
381    /// means `admitted_total() >= to_create` (defensive `>=`). A weaker
382    /// `> 0` bar let a PARTIAL admit pass — the live set is 9 SHIP + 8 SLAP
383    /// in ONE tx with independent per-topic admission, so tm_ship admitting
384    /// its 9 while tm_slap refused all 8 read as success while the diff
385    /// re-created (re-paid for) the refused 8 every cycle. Subsumes the
386    /// converged no-op (`0 >= 0`).
387    #[must_use]
388    pub fn effective(&self) -> bool {
389        self.ok() && self.admitted_total() >= self.to_create
390    }
391}
392
393/// Internal result from Phase 1 + 2 validation.
394///
395/// Carries per-topic admittance decisions, previous coins, and the parsed
396/// transaction so that Phase 3 (mutations) can proceed without re-parsing.
397struct TopicValidation {
398    topic: String,
399    is_dupe: bool,
400    /// Input indices that spend previously-admitted outputs from this topic.
401    previous_coins: Vec<u32>,
402    /// The previous outputs found in storage (parallel to previous_coins).
403    previous_outputs: Vec<Output>,
404    admittance: AdmittanceInstructions,
405    failed: bool,
406    /// The FIRST storage read fault hit while scanning this topic's
407    /// previous outputs (S2, bsv-low 2026-08-29). A faulted read makes a
408    /// spend look like it consumes nothing — the settle's spend pointer is
409    /// then never written — so it is carried into the [`MutationReport`]
410    /// and blocks the `applied_transactions` record for this topic: a
411    /// replay re-reads instead of being deduplicated away. With the read
412    /// that faulted: `find_output` (a previous coin) or
413    /// `does_applied_transaction_exist` (the dedup read, the delta fold of
414    /// 2026-10-07, H3).
415    read_fault: Option<(&'static str, String)>,
416}
417
418/// One Phase-3 write — or the validation read it depends on — that FAILED
419/// during a submit (S2 queue-durable admission, bsv-low 2026-08-29).
420///
421/// Before this existed every such failure was `error!`-logged and swallowed
422/// while `submit` returned `Ok(steak)`: under a D1-overload storm the
423/// overlay ACKED admissions whose writes never landed (the 2026-08-26
424/// phantom class — `c4d2ed06…`/`62c2a9b3…` absent from the engine store,
425/// beta D1 queried directly). A fault is now a VALUE the caller can act on
426/// (re-queue for an idempotent replay) instead of a log line nobody reads.
427#[derive(Debug, Clone, PartialEq, Eq)]
428pub struct MutationFault {
429    /// The topic whose mutation faulted.
430    pub topic: String,
431    /// The write (or read) that failed: `insert_output`,
432    /// `mark_utxo_as_spent`, `update_consumed_by`, `delete_utxo_deep`,
433    /// `insert_applied_transaction`, `find_output` (validation read),
434    /// `does_applied_transaction_exist` (the dedup read),
435    /// `lookup_service.output_spent`, `lookup_service.output_admitted_by_topic`,
436    /// `undo_insert_output` (the undo of a faulted submit's inserts),
437    /// `record_spent_coin_applied` (the applied row a spender writes for the
438    /// transaction of a coin it deletes), `predecessor_not_landed`.
439    pub site: &'static str,
440    /// The backend's own error text.
441    pub error: String,
442}
443
444/// What Phase 3 actually landed for a submit — the durability report.
445///
446/// `applied_topics` are the topics whose EVERY write (and lookup-service
447/// notification) succeeded and were therefore recorded in
448/// `applied_transactions`. A topic with any fault is deliberately NOT
449/// recorded: the dedup in Phase 1 would otherwise turn a replay into a
450/// no-op and make the loss permanent (the ordering was already
451/// write-then-record; the record is now conditional on the writes).
452#[derive(Debug, Default, Clone, PartialEq, Eq)]
453pub struct MutationReport {
454    pub faults: Vec<MutationFault>,
455    pub applied_topics: Vec<String>,
456    /// Topics this (txid, topic) pair had ALREADY been applied under — the
457    /// Phase-1 dedup skipped them, so their STEAK entry is an empty default
458    /// that means "nothing new", never "judged another tx" (loop-3 client
459    /// belt re-presents read the empty STEAK as index-pending).
460    pub deduped_topics: Vec<String>,
461    /// The predecessors the door LANDED first from this submit's BEEF, as
462    /// `(txid, topic)`, ancestors first ([`Engine::land_carried`]; the E1D
463    /// delta fold, L2). Each is an admission write of its own that no
464    /// caller asked for: a caller that guards its admission writes (the
465    /// worker's eviction ledger, bsv-low #513) guards these too.
466    pub landed_predecessors: Vec<(String, String)>,
467    /// The submit's own script walk could not run within the engine's
468    /// budget (bsv-low #592) and the submit went on as under
469    /// `HistoricalTxNoSpv` ([`WalkBreachPolicy::NetworkAccepted`] only; under
470    /// `NotNow` a breach is [`EngineError::WalkCouldNotRun`]). `None` when it
471    /// ran (or the mode does not walk). A caller counts it; it never makes a
472    /// report undurable.
473    pub walk_could_not_run: Option<WalkCouldNotRun>,
474    /// The walks of the carried predecessors this submit tried to land
475    /// ([`Engine::land_carried`]) that could not run within the budget (the
476    /// E592 lens fold, L3): under `NotNow` each one's landing did not happen
477    /// (the successor waits), under `NetworkAccepted` each went on. Counted
478    /// by the caller beside `walk_could_not_run`.
479    pub landed_walks_could_not_run: Vec<WalkCouldNotRun>,
480}
481
482/// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): what [`Engine::renotify_admitted`] did.
483#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
484pub struct RenotifyReport {
485    /// stored outputs of (txid, topic) the engine had ALREADY admitted
486    pub outputs: u32,
487    /// lookup-service notifications that returned Ok (outputs × services)
488    pub notified: u32,
489    /// notifications that failed — `<service>: <error>`; the others still ran
490    pub faults: Vec<String>,
491    /// the output indexes re-notified (the caller's spend discovery keys on them)
492    pub vouts: Vec<u32>,
493}
494
495impl MutationReport {
496    fn fault(&mut self, topic: &str, site: &'static str, error: String) {
497        self.faults.push(MutationFault {
498            topic: topic.to_string(),
499            site,
500            error,
501        });
502    }
503
504    /// True when every mutation this submit needed landed (nothing to
505    /// replay). A dupe-only submit is durable (there was nothing to write).
506    #[must_use]
507    pub fn is_durable(&self) -> bool {
508        self.faults.is_empty()
509    }
510
511    /// One log line naming every fault (`topic/site: error`), each error
512    /// clipped so a D1 stack dump cannot flood the log stream.
513    #[must_use]
514    pub fn summary(&self) -> String {
515        self.faults
516            .iter()
517            .map(|f| {
518                let err: String = f.error.chars().take(120).collect();
519                format!("{}/{}: {}", f.topic, f.site, err)
520            })
521            .collect::<Vec<_>>()
522            .join("; ")
523    }
524}
525
526/// The DOOR's static work bound (bsv-low W-A gate MED-1, 2026-09-09). Bitcoin
527/// script has no loops: every opcode runs at most once, so an input's work is
528/// bounded BEFORE execution by its opcode census times the interpreter's
529/// element limit (the stack memory limit bounds every element, and every
530/// CAT / NUM2BIN / SPLIT result with it) plus its signature checks times the
531/// transaction's own size or a floor, whichever is more (a BIP-143 preimage
532/// hashes the prevouts and the outputs; the EC verification costs the same
533/// whatever the transaction weighs). The estimate is computed from the bytes;
534/// nothing executes past the budget, and a budget breach is the DOOR's
535/// verdict (inconclusive: the network judges), never the interpreter's.
536///
537/// THE BODY IS NOT COUNTED (bsv-low #585, door 1). Until #585 the budget also
538/// counted the BODY: 64 unproven transactions, 256 inputs per transaction,
539/// 512 KB per transaction, and the parse's own size and counts. A valid BEEF
540/// is never left unjudged for its size or its counts, so those are gone: the
541/// walk reads the body through the streaming reader ([`crate::script_door`]).
542///
543/// TWO LIMBS, both a budget whose breach is "the network judges" and never a
544/// refusal: the WORK the interpreter will do, and the MEMORY the door will
545/// hold beside the caller's body. Each is estimated from the bytes before it
546/// is spent.
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub struct DoorBudget {
549    /// The interpreter's stack memory limit: bounds every element, and is the
550    /// unit of the work estimate (a hash-class opcode is charged one element).
551    /// Not a bound on the BEEF: a script that trips it is "the network
552    /// judges", as a breach of the work is.
553    pub memory_limit: usize,
554    /// The whole walk's estimated work, in bytes hashed or pushed.
555    pub max_work_bytes: u64,
556    /// The least a signature check is charged (the doors lens E585-D12-M1).
557    /// A check is charged `max(its transaction's bytes, this)`: the digest
558    /// hashes about the transaction, and the EC verification behind it costs
559    /// the same whatever the transaction weighs (about 95 us native). Charged
560    /// the transaction's bytes alone, a 106 byte `OP_CHECKSIG OP_NOT`
561    /// transaction bought a verification for 106 bytes of budget: 90,000 of
562    /// them walked at a fifth of the budget in 9.6 s. With the floor the
563    /// budget bounds the EC verifications of one walk at
564    /// `max_work_bytes / sig_check_floor`.
565    pub sig_check_floor: u64,
566    /// The whole walk's estimated memory beside the body, in bytes (the doors
567    /// lens E585-D12-L3): what the index, the stream's element in hand and
568    /// the walk's layout of a transaction hold, estimated from the frame's
569    /// lengths and counts by [`crate::stream_sizing::estimate`] BEFORE the
570    /// stream is opened; and, per input, what its two scripts hold once
571    /// parsed and run (the delta lens E585-D12-DELTA-M1: bsv-rs's chunk
572    /// records, their clones, a signature's subscript, the empty stack
573    /// entries the interpreter's memory limit does not count), charged from
574    /// the scripts' chunk count and bytes BEFORE either is parsed
575    /// (`script_door::script_parse_charge`, 512 bytes a chunk and 8 a byte).
576    ///
577    /// How the two compose: the frame's estimate is the most the walk holds
578    /// at any moment apart from the input being run (what is kept, and the
579    /// heaviest transaction in hand, counted once with its raw bytes and the
580    /// interpreter's raw copies of the unlocking script); the scripts' charge
581    /// is the EXPANSION of one input's scripts past those bytes, held while
582    /// that input runs and released after it. An input is run only while the
583    /// frame's estimate plus its own charge is within this limb.
584    pub max_memory_bytes: u64,
585}
586
587impl DoorBudget {
588    /// Sized for LOW's real shapes with a wide margin. What the static census
589    /// BOUNDS: hashing (hash-class ops × the element limit) and signature
590    /// checks (× the transaction's size or the floor, whichever is more; a
591    /// CHECKMULTISIG weighted by its static key count when the count is a
592    /// small-int push, else by the most keys the element limit admits).
593    ///
594    /// THE FLOOR, 64 KiB (half the hash charge), chosen against LOW's shapes
595    /// as the pin `door_low_shapes_sit_under_a_quarter_of_the_work_budget`
596    /// measures them (work, of the 64 MiB; memory, the frame's and the
597    /// largest input's scripts, of the 48 MiB limb; work before the floor in
598    /// brackets):
599    ///
600    /// | shape | inputs | work | memory |
601    /// |---|---|---|---|
602    /// | 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 % |
603    /// | the real covenant refund | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
604    /// | 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 % |
605    /// | STRESS, not LOW's shape: nine seats, three hops each | 36 | 7,082,619, 10.6 % (4,740,838) | 36,420, 0.07 % |
606    /// | a coin 30 P2PKH hops deep | 30 | 5,902,188, 8.8 % (3,941,856) | 23,720, 0.05 % |
607    ///
608    /// A P2PKH input is charged 196,739 bytes (a hash opcode at
609    /// the element limit, a check at the floor, its 131 script bytes): 341 of
610    /// them are the budget, 85 a quarter of it. The bound on EC verifications
611    /// per walk is 64 MiB / 64 KiB = 1,024 (1,023 with their script bytes):
612    /// measured natively, 1,000 of them walk in 87 ms in the release profile
613    /// (1.4 to 1.8 s in a debug build), and the lens's 17,000 stop at the
614    /// 1,024th in 108 ms. wasm32 was not measured.
615    ///
616    /// THE MEMORY, 48 MiB: three eighths of a 128 MB isolate. Beside it stand,
617    /// for a 10 MB request (the figure the arithmetic is made at: since NL-6
618    /// the route caps no body, so a heavier one is the isolate's), the
619    /// request's body, the completed BEEF the route hands the door (a second
620    /// copy of up to as much) and the EF batch (the route's 2 MB bound, twice
621    /// while it is serialized): 24 MB, which leaves 56 MB for the module, the
622    /// runtime, the allocator's fragmentation and what a native estimate does
623    /// not see of wasm32. The limb bounds the walk (the door's, and since
624    /// bsv-low #592 the submit's own and the GASP anchor's under
625    /// [`Engine::set_walk_budget`]) and nothing else: the route's own
626    /// hydrated parses before it are bounded by the isolate only (the land
627    /// lens E585-LAND-M1: 1.43 GB natively for a 9.9 MB body of 900,000
628    /// minimal transactions, before the route's 429). What
629    /// it lets through, by the door's charges (`script_door::DOOR_CHARGES`,
630    /// each an upper bound: the pins measure 1.3 to 5.3 times the heap the
631    /// walk reaches): a BUMP of up to 72,944 leaves (690 bytes a level-0
632    /// leaf); a single transaction of up to about 12 MB (4 bytes a byte)
633    /// whose walked inputs' scripts are few chunks (a script is charged 520
634    /// bytes a one-byte opcode and 8 a pushed byte: an input whose two
635    /// scripts are about 96,000 opcodes, or a 1 MB `OP_NOP` lock, is past
636    /// the limb before it is parsed, the network judges; LOW's covenant leg,
637    /// 6,608 script bytes, is charged 1.3 MB); a 10 MB body of one-input,
638    /// one-output P2PKH transactions (191 bytes, charged 570: 30 MB). What it
639    /// stops: 111,848 transactions of no input and no output, whatever
640    /// follows them.
641    ///
642    /// THE OPEN RESIDUAL (the W-A gate's delta-verify, 2026-09-09, measured):
643    /// the interpreter itself meters nothing, so two classes stay charged
644    /// only by their script bytes — bignum arithmetic (`OP_MUL`/`OP_DIV`/
645    /// `OP_MOD` on operands up to the element limit: ~1.3 ms per 30 KB×30 KB
646    /// round, linear in rounds) and `OP_NUM2BIN`, which
647    /// allocates its size operand BEFORE the memory limit is consulted (up to
648    /// bsv-rs's 1 GB element size: an isolate kill, not a refusal). None of
649    /// them can refuse a spend (a breach and a trip are the door's bound and
650    /// the request proceeds); all of them cost the beta overlay CPU until
651    /// bsv-rs meters work dynamically (a `work_limit` charged per op with the
652    /// real operand sizes, and a pre-allocation check in NUM2BIN) — OWED
653    /// before any PROD flip of `SCRIPT_VERIFY_NETWORK_GATED`. Since #585 the
654    /// count of such rounds is bounded by the body alone (the script bytes
655    /// are charged, the transactions and inputs are not counted). A third,
656    /// as before #585 (the doors lens N7): an opcode that copies an element
657    /// (`OP_DUP`, `OP_CAT`, `OP_PICK`) is charged its one script byte and can
658    /// copy up to the element limit. The class the floor CLOSED: a
659    /// CHECKMULTISIG whose key count is a computed value was charged the most
660    /// keys the element limit admits (3,971) at its transaction's bytes and
661    /// run (~80 µs per key tried); at the floor those are 260 MB, past the
662    /// budget, so such an input is never run at the door (the network
663    /// judges). LOW's covenant states its count (`OP_3`).
664    pub const DEFAULT: DoorBudget = DoorBudget {
665        memory_limit: 128 * 1024,
666        max_work_bytes: 64 * 1024 * 1024,
667        sig_check_floor: 64 * 1024,
668        max_memory_bytes: 48 * 1024 * 1024,
669    };
670}
671
672/// Which limb of the [`DoorBudget`] a walk went past.
673#[derive(Debug, Clone, Copy, PartialEq, Eq)]
674pub enum DoorLimb {
675    /// The static work estimate, or the interpreter's own memory limit
676    /// tripping inside one input.
677    Work,
678    /// The estimate of what the door holds beside the body.
679    Memory,
680}
681
682/// What [`Engine::run_validation`] answers: the topics' validations, the
683/// preliminary STEAK, the subject and its txid, and whether the walk could
684/// not run within the budget (bsv-low #592).
685type Validated = (
686    Vec<TopicValidation>,
687    Steak,
688    Transaction,
689    String,
690    Option<WalkCouldNotRun>,
691);
692
693/// Which limb of the [`DoorBudget`] a submit's own walk went past (bsv-low
694/// #592): the [`DoorLimb`] of the door's error, with the interpreter's own
695/// memory limit told apart from the static work estimate.
696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
697pub enum WalkLimb {
698    /// The static work estimate.
699    OverWork,
700    /// The estimate of what the walk holds beside the body.
701    OverMemory,
702    /// The interpreter's own memory limit (the budget's element limit)
703    /// tripping inside one input.
704    InterpreterMemory,
705}
706
707impl WalkLimb {
708    /// The door's word for the same limb ([`DoorLimb`]: the interpreter's
709    /// trip is `Work` there).
710    #[must_use]
711    pub fn door_limb(self) -> DoorLimb {
712        match self {
713            WalkLimb::OverWork | WalkLimb::InterpreterMemory => DoorLimb::Work,
714            WalkLimb::OverMemory => DoorLimb::Memory,
715        }
716    }
717
718    /// The limb's name in a log line and a counter's reason.
719    #[must_use]
720    pub fn as_str(self) -> &'static str {
721        match self {
722            WalkLimb::OverWork => "over_work",
723            WalkLimb::OverMemory => "over_memory",
724            WalkLimb::InterpreterMemory => "interpreter_memory",
725        }
726    }
727}
728
729/// What a submit does when its own script walk could not run within the
730/// engine's [`DoorBudget`] (bsv-low #592; the E592 lens fold, H1, the owner's
731/// ruling 3a read with the lens): a breach is NEVER an admission without the
732/// network's word on record.
733///
734/// The work limb is a static estimate from the bytes, and a LOCK can be made
735/// to pass it cheaply (a bare `OP_CHECKMULTISIG` in a fabricated parent's
736/// lock is charged 3,971 keys). So a breach says nothing about whether the
737/// spend is valid, and on a path where nothing else judges it an admission
738/// would admit a spend nobody checked: the lens's forged spend of a held coin.
739#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
740pub enum WalkBreachPolicy {
741    /// "Not now" ([`EngineError::WalkCouldNotRun`]): nothing judged, nothing
742    /// written, never a final refusal. Every door but one: the ungated
743    /// modes, `/admin/readmit`, the peer crawler, a library consumer's
744    /// submit; the GASP anchor HOLDS its UTXO the same way.
745    #[default]
746    NotNow,
747    /// The network's ACCEPT of these bytes is on record (the queue's replay
748    /// of a gated submission, whose producer broadcast them and marked the
749    /// message): the submit goes on exactly as under `HistoricalTxNoSpv`,
750    /// reported on [`MutationReport::walk_could_not_run`]. The roots the walk
751    /// reached are still checked, and a bad one is still the `SpvError` it is.
752    NetworkAccepted,
753}
754
755/// A submit whose own script walk COULD NOT RUN within the engine's
756/// [`DoorBudget`] (bsv-low #592, the owner's ruling 3a of 2026-10-10). Shaped
757/// like [`EngineError::ScriptWalkOverBudget`]. Under
758/// [`WalkBreachPolicy::NotNow`] it is carried by
759/// [`EngineError::WalkCouldNotRun`] (not now); under
760/// [`WalkBreachPolicy::NetworkAccepted`] the submit went on as under
761/// `HistoricalTxNoSpv` and this says so on its [`MutationReport`]. The
762/// reference (`Engine.submit`, `tx.verify`) has no budget; this is the
763/// platform's addition, on the door's lineage (D8).
764#[derive(Debug, Clone, PartialEq, Eq)]
765pub struct WalkCouldNotRun {
766    /// The submitted transaction.
767    pub subject_txid: String,
768    /// The transaction the walk stopped at.
769    pub at_txid: String,
770    /// Whether the subject's own inputs had all executed before the stop.
771    pub subject_judged: bool,
772    /// The limb it went past.
773    pub limb: WalkLimb,
774    /// The estimate against the limb, in the door's words.
775    pub what: String,
776}
777
778/// What the walk did (the door's cost instrument, since `Date.now()` is
779/// frozen during synchronous work on Workers and cannot time it).
780#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
781pub struct WalkStats {
782    /// Unproven transactions whose inputs were executed.
783    pub unproven_txs: usize,
784    /// Inputs executed (scripts run).
785    pub inputs_executed: usize,
786    /// Unlocking + locking script bytes executed.
787    pub script_bytes: usize,
788    /// Hash-class opcodes seen (SHA1/SHA256/HASH160/HASH256/RIPEMD160).
789    pub hash_ops: usize,
790    /// Signature checks seen (CHECKSIG counts 1, CHECKMULTISIG counts its
791    /// consensus maximum of 20).
792    pub sig_ops: usize,
793    /// The static work estimate charged against the budget.
794    pub work_bytes: u64,
795    /// The memory estimate charged against the budget: the frame's, made
796    /// before the stream was opened, plus the largest charge of one walked
797    /// input's scripts, made before they were parsed.
798    pub memory_bytes: u64,
799    /// Whether the SUBJECT's own inputs were all executed (an ancestor may
800    /// still have faulted afterwards).
801    pub subject_judged: bool,
802}
803
804/// Static opcode census of one input's scripts: (bytes, hash ops, sig ops).
805///
806/// A CHECKMULTISIG is weighted by its KEY COUNT: the small-int push that
807/// precedes it when the script states the count (the Poc5 covenant's
808/// `OP_3`), else the most 33-byte keys the element limit admits (the count is
809/// a computed value the census cannot see; bsv-rs allows up to `i32::MAX`).
810pub(crate) fn script_census(
811    unlocking: &[u8],
812    locking: &[u8],
813    memory_limit: usize,
814) -> Result<(usize, usize, usize), String> {
815    use bsv_rs::script::op::*;
816    let most_keys = (memory_limit / 33).max(1);
817    let mut hash_ops = 0usize;
818    let mut sig_ops = 0usize;
819    for (label, bytes) in [("unlocking", unlocking), ("locking", locking)] {
820        let script = bsv_rs::script::Script::from_binary(bytes)
821            .map_err(|e| format!("{label} script does not parse: {e}"))?;
822        let chunks = script.chunks();
823        for (i, chunk) in chunks.iter().enumerate() {
824            match chunk.op {
825                OP_RIPEMD160 | OP_SHA1 | OP_SHA256 | OP_HASH160 | OP_HASH256 => hash_ops += 1,
826                OP_CHECKSIG | OP_CHECKSIGVERIFY => sig_ops += 1,
827                OP_CHECKMULTISIG | OP_CHECKMULTISIGVERIFY => {
828                    let stated = i
829                        .checked_sub(1)
830                        .map(|j| chunks[j].op)
831                        .and_then(|op| match op {
832                            OP_1..=OP_16 => Some((op - OP_1 + 1) as usize),
833                            _ => None,
834                        });
835                    sig_ops += stated.unwrap_or(most_keys);
836                }
837                _ => {}
838            }
839        }
840    }
841    Ok((unlocking.len() + locking.len(), hash_ops, sig_ops))
842}
843
844impl Engine {
845    /// Page size used by `/requestSyncResponse` when the (public) caller
846    /// omits `limit`. A bounded page is not lossy for a conforming
847    /// initiator: ours ([`crate::gasp::GASPSync`]) keeps requesting pages
848    /// for as long as the `since` score cursor advances (gate finding M1 —
849    /// it must NOT require a "full" page to continue, because this clamp
850    /// makes a full-by-the-initiator's-limit page impossible).
851    pub const SYNC_RESPONSE_DEFAULT_LIMIT: u64 = 500;
852    /// Hard cap on a `/requestSyncResponse` page. Before bsv-low #291 an
853    /// absent/huge caller-supplied limit meant an unbounded scan of the
854    /// outputs table on a public route.
855    pub const SYNC_RESPONSE_MAX_LIMIT: u64 = 1000;
856
857    /// Clamp the public `/requestSyncResponse` page size: absent → the
858    /// default page; anything larger than the cap → the cap. The result is
859    /// ALWAYS `Some`-worthy — a sync response is never unbounded (bsv-low
860    /// #291). Completeness relies on the INITIATOR paging while its `since`
861    /// cursor advances (see `SYNC_RESPONSE_DEFAULT_LIMIT`'s doc) — the
862    /// responder alone cannot page for it.
863    pub fn clamp_sync_limit(requested: Option<u64>) -> u64 {
864        requested
865            .unwrap_or(Self::SYNC_RESPONSE_DEFAULT_LIMIT)
866            .min(Self::SYNC_RESPONSE_MAX_LIMIT)
867    }
868
869    /// Create a new Overlay Services Engine.
870    pub fn new(
871        managers: HashMap<String, Box<dyn TopicManager>>,
872        lookup_services: HashMap<String, Box<dyn LookupService>>,
873        storage: Box<dyn Storage>,
874        advertiser: Option<Box<dyn Advertiser>>,
875        config: EngineConfig,
876    ) -> Self {
877        Self::with_chain_tracker(
878            managers,
879            lookup_services,
880            storage,
881            advertiser,
882            None,
883            None,
884            config,
885        )
886    }
887
888    /// Create a new Engine with an optional ChainTracker for SPV verification.
889    pub fn with_chain_tracker(
890        managers: HashMap<String, Box<dyn TopicManager>>,
891        lookup_services: HashMap<String, Box<dyn LookupService>>,
892        storage: Box<dyn Storage>,
893        advertiser: Option<Box<dyn Advertiser>>,
894        broadcaster: Option<Box<dyn Broadcaster>>,
895        chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
896        config: EngineConfig,
897    ) -> Self {
898        Self::with_all(
899            managers,
900            lookup_services,
901            storage,
902            advertiser,
903            broadcaster,
904            None,
905            chain_tracker,
906            config,
907        )
908    }
909
910    /// Create a new Engine with all optional components.
911    #[allow(clippy::too_many_arguments)]
912    pub fn with_all(
913        managers: HashMap<String, Box<dyn TopicManager>>,
914        lookup_services: HashMap<String, Box<dyn LookupService>>,
915        storage: Box<dyn Storage>,
916        advertiser: Option<Box<dyn Advertiser>>,
917        broadcaster: Option<Box<dyn Broadcaster>>,
918        arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
919        chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
920        mut config: EngineConfig,
921    ) -> Self {
922        // Build default sync configuration: SHIP for all topics except tm_ship/tm_slap
923        // which get their trackers merged with provided shipTrackers/slapTrackers.
924        for manager_name in managers.keys() {
925            if manager_name == "tm_ship" {
926                if matches!(
927                    config.sync_configuration.get(manager_name),
928                    Some(SyncTarget::Disabled)
929                ) {
930                    continue;
931                }
932                let mut combined: HashSet<String> = HashSet::new();
933                if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
934                {
935                    combined.extend(peers.iter().cloned());
936                }
937                combined.extend(config.ship_trackers.iter().cloned());
938                if !combined.is_empty() {
939                    config.sync_configuration.insert(
940                        manager_name.clone(),
941                        SyncTarget::Peers(combined.into_iter().collect()),
942                    );
943                }
944            } else if manager_name == "tm_slap" {
945                if matches!(
946                    config.sync_configuration.get(manager_name),
947                    Some(SyncTarget::Disabled)
948                ) {
949                    continue;
950                }
951                let mut combined: HashSet<String> = HashSet::new();
952                if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
953                {
954                    combined.extend(peers.iter().cloned());
955                }
956                combined.extend(config.slap_trackers.iter().cloned());
957                if !combined.is_empty() {
958                    config.sync_configuration.insert(
959                        manager_name.clone(),
960                        SyncTarget::Peers(combined.into_iter().collect()),
961                    );
962                }
963            } else if !config.sync_configuration.contains_key(manager_name) {
964                config
965                    .sync_configuration
966                    .insert(manager_name.clone(), SyncTarget::Ship);
967            }
968        }
969
970        Self {
971            managers,
972            lookup_services,
973            storage,
974            advertiser,
975            broadcaster,
976            arc_broadcaster,
977            chain_tracker,
978            gasp_remote_factory: None,
979            ancestor_fetcher: None,
980            peer_sync_budget: None,
981            graph_budget: None,
982            graph_budget_limbs: (
983                crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES,
984                crate::gasp::DEFAULT_GRAPH_BUDGET_NODES,
985            ),
986            finalize_submit_budget: None,
987            finalize_gate: crate::gasp::SubmitGate::default(),
988            not_landed: std::cell::RefCell::new(HashSet::new()),
989            landing_guard: None,
990            verify_scripts: true,
991            walk_budget: DoorBudget::DEFAULT,
992            config,
993        }
994    }
995
996    /// Set the ARC broadcaster for network broadcast to miners.
997    ///
998    /// When set, the Engine broadcasts transactions to ARC during Phase 2
999    /// of `submit()` for `CurrentTx` submissions.
1000    pub fn set_arc_broadcaster(&mut self, arc: Box<dyn ArcBroadcaster>) {
1001        self.arc_broadcaster = Some(arc);
1002    }
1003
1004    /// Set the GASP remote factory for peer communication during sync.
1005    ///
1006    /// Platform-specific crates (like overlay-cloudflare) provide an implementation
1007    /// that creates HTTP-based remotes using their native fetch API.
1008    pub fn set_gasp_remote_factory(&mut self, factory: Box<dyn crate::gasp::GASPRemoteFactory>) {
1009        self.gasp_remote_factory = Some(factory);
1010    }
1011
1012    /// Set the OPT-IN chain-backed ancestor fetcher for GASP ingest.
1013    ///
1014    /// **OFF BY DEFAULT.** When set, a peer's failure to serve a needed
1015    /// ancestor during GASP ingest falls back to fetching that ancestor's raw
1016    /// tx from chain (via the supplied fetcher), self-healing the graph and
1017    /// also enabling strict-BEEF finalize so missing ancestry fails loud.
1018    ///
1019    /// This is a deliberate one-time-migration escape hatch. Production must
1020    /// NOT call this — when unset, GASP ingest behavior is unchanged.
1021    pub fn set_ancestor_fetcher(&mut self, fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>) {
1022        self.ancestor_fetcher = Some(fetcher);
1023    }
1024
1025    /// Set the admission predicate asked BEFORE the door lands a carried
1026    /// predecessor (bsv-low #575, the E1D delta-2 fold, L2;
1027    /// [`Engine::land_carried`]). It is asked once per body the landing
1028    /// would submit, before that body's submit (a dry one included) and
1029    /// before its reads are charged; an `Err` (the caller's refusal, or its
1030    /// own read fault) ends the landing with nothing of that body written
1031    /// and the successor's topic answers "not now", as on `683dffd`. The
1032    /// worker installs its eviction ledger here: an OPEN eviction of the
1033    /// body refuses it. Its read is not one of the submit's 256 (it is the
1034    /// caller's store, at most one per landed body). Unset (default): every
1035    /// carried predecessor may land. What the predicate cannot see (a row
1036    /// opened while the landing writes) stays the caller's to guard after
1037    /// the write ([`MutationReport::landed_predecessors`]).
1038    pub fn set_landing_guard(&mut self, guard: LandingGuard) {
1039        self.landing_guard = Some(guard);
1040    }
1041
1042    /// Set the per-peer GASP sync budget (bsv-low#302).
1043    ///
1044    /// `sleep` is the platform sleep factory (ms → future); `budget_ms` is
1045    /// the wall-clock slice ONE peer's sync may consume. A peer exceeding it
1046    /// is dropped (loudly logged, listed in the topic's `errors`) and the
1047    /// loop moves on. Unset (default) = unbounded, the pre-#302 behavior.
1048    ///
1049    /// **Progress survives the deadline (bsv-low #552).** With a budget set,
1050    /// every graph is submitted AS IT FINALIZES, inside the raced future (the
1051    /// reference submits inside `finalizeGraph` too), so what was finalized
1052    /// before the deadline stays admitted. At the deadline the cursor is
1053    /// advanced to `GASPSync::completed_cursor`: past the UTXOs whose graphs
1054    /// were completed, never past the one in flight. That graph is lost
1055    /// whole (nothing of it was finalized) and the next tick walks it again,
1056    /// down to whatever is now in storage.
1057    ///
1058    /// **The deadline never lands inside a transaction's writes** (the lens
1059    /// fold's HIGH-1). It is cooperative around a finalize submit
1060    /// ([`Engine::finalize_submit_gate`]): a deadline that falls due while
1061    /// one transaction is being written waits for that transaction, and the
1062    /// sync is dropped at the boundary. What a dropped tick leaves of the
1063    /// graph being submitted is an ancestors-first prefix of WHOLE
1064    /// transactions, with the cursor below its UTXO. Everywhere else (a
1065    /// request to the peer, the walk, the anchor check) the deadline drops
1066    /// the sync at once, as a budget against a dead peer must. A dropped tick that finalized a
1067    /// graph or moved the cursor is recorded as a SUCCESSFUL attempt for the
1068    /// quarantine count: a peer serving a long bootstrap is not a dead peer.
1069    /// One with no progress is a failed attempt, as before.
1070    ///
1071    /// The budget bounds the work of a TICK. Without a per-graph budget
1072    /// nothing bounds a GRAPH (parity: the reference has no node cap): one
1073    /// graph whose own walk outlasts the budget is dropped on every tick
1074    /// (`deadline_dropped_graphs`) and never admitted. With one
1075    /// ([`Engine::set_graph_budget`], bsv-low #555) that walk is deferred
1076    /// with its progress kept, here too, and resumed next tick.
1077    pub fn set_peer_sync_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1078        self.peer_sync_budget = Some((sleep, budget_ms));
1079    }
1080
1081    /// Set the per-GRAPH GASP budget and turn DEFERRAL on (bsv-low #555).
1082    ///
1083    /// A graph whose walk makes `max_calls` requests (to the peer, or chain
1084    /// fetches) or runs `budget_ms` in one pass is DEFERRED: its partial walk
1085    /// (the nodes fetched with their proofs, the inputs still pending, the
1086    /// calls spent, its age in passes) is saved as ONE record of the storage
1087    /// ([`Storage::put_deferred_graph`], replaced on every deferral), its
1088    /// UTXO is held below the cursor as a failed one is (the gap guard) and
1089    /// not walked again in the sync (#554), and the sync goes on to the next
1090    /// UTXO and topic. The next sync that is served that UTXO RESUMES the
1091    /// walk from the record, asking only what is pending, under the same
1092    /// budget; a graph so converges over passes and is then completed as
1093    /// any graph is (#551's anchor check, the finalize submits, e1d's
1094    /// rules). A walk cut by the per-peer deadline is deferred the same way.
1095    ///
1096    /// Bounds: a record deferred [`crate::gasp::DEFERRED_GRAPH_MAX_PASSES`]
1097    /// times is dropped with its reason and the UTXO fails as before (the
1098    /// gap guard asks again, from the root); a record has no byte bound
1099    /// (bsv-low #585); at most
1100    /// [`crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC`] records per (peer,
1101    /// topic). Keep `budget_ms` below the per-peer budget so that a deep
1102    /// graph leaves the pass time for the UTXOs after it. Defaults offered:
1103    /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_CALLS`],
1104    /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_MS`].
1105    ///
1106    /// Every resume of one sync shares ONE such budget (the lens fold's M1),
1107    /// so the deferred UTXOs, served first, leave the pass the rest; a walk
1108    /// that fetched nothing keeps no record and is a failed attempt for the
1109    /// quarantine (H1); a walk that cannot be kept, or whose save faulted,
1110    /// goes on under the per-peer budget alone (L4, the delta fold's D-L2);
1111    /// a FRESH walk's error keeps no record (D-M1). With a budget, a sync the
1112    /// peer served work that finalized no graph and moved no cursor is
1113    /// YIELDLESS, and past [`crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED`] in a
1114    /// row AND [`crate::gasp::PEER_YIELDLESS_SECS_ALLOWED`] since the streak
1115    /// began each is a failed attempt (D-M2, the delta-2 fold's D2-M1; the
1116    /// streak is kept by [`Storage::record_peer_sync_yield`], keyed on the
1117    /// peer's [`crate::gasp::peer_origin`] as its quarantine is). A FRESH
1118    /// walk that faults after half its calls or half its time keeps its
1119    /// record (D2-L1); a RESUMED one that faults having appended nothing
1120    /// [`crate::gasp::DEFERRED_GRAPH_MAX_IDLE_FAULTS`] passes in a row is
1121    /// dropped (D2-M2). Each record says whether its peer is configured
1122    /// (`SyncTarget::Peers`), for a storage that reserves part of its
1123    /// ceiling for those (the worker reserves half).
1124    ///
1125    /// Unset (the default) nothing is deferred and the walk is the one
1126    /// before #555 (parity: the reference has no budget and no deferral).
1127    /// The storage must KEEP records (the four required deferred-graph
1128    /// methods of [`Storage`]): over one that refuses every save, every
1129    /// graph past the budget is walked from its root under the per-peer
1130    /// budget alone on every pass, as before #555, so do not set a budget
1131    /// there.
1132    ///
1133    /// The budget has two more limbs (bsv-low #586), in force with it at
1134    /// their defaults: the bytes one graph may be SERVED in one pass
1135    /// ([`crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES`], a budget per pass only:
1136    /// a record has no byte bound since bsv-low #585 door 4) and the nodes it
1137    /// may APPEND ([`crate::gasp::DEFAULT_GRAPH_BUDGET_NODES`], 64);
1138    /// [`Engine::set_graph_budget_limbs`] names others.
1139    pub fn set_graph_budget(&mut self, sleep: SleepFactory, max_calls: u32, budget_ms: u64) {
1140        self.graph_budget = Some((sleep, max_calls, budget_ms));
1141    }
1142
1143    /// The bytes and nodes limbs of the per-graph budget (bsv-low #586,
1144    /// zanaadu-v2 #377): a graph whose walk is SERVED `max_bytes_fetched`
1145    /// bytes in one pass (the hex of each node's raw transaction and proof,
1146    /// as the peer sends it and a record keeps it, every answer counted), or
1147    /// APPENDS `max_nodes` nodes, is DEFERRED exactly as at the call budget
1148    /// (reason `bytes` or `nodes`): its record saved, its UTXO held below
1149    /// the cursor, the walk resumed by the next pass from what is pending.
1150    /// Never a drop, a refusal or a discarded graph: these are budgets PER
1151    /// PASS, not limits on a graph or a BEEF. A pass always makes one
1152    /// request whatever a node's size; the resumes of one sync share one
1153    /// such budget, as they share its calls (M1); a walk that cannot be
1154    /// KEPT goes on under the per-peer budget alone (L4), as at the calls.
1155    /// They bound what one pass adds to the pending graph and to its record
1156    /// while the anchor check and the finalize hold it in memory. No effect
1157    /// until [`Engine::set_graph_budget`] turns deferral on; the reference
1158    /// has neither. This setter is UNCAPPED, as the worker's
1159    /// `GASP_GRAPH_BUDGET_BYTES` var is (bsv-low #585 door 4: the engine keeps
1160    /// no record cap for the limb to sit under). A consumer whose STORAGE caps
1161    /// a record must keep the bytes limb under that cap, or a pass whose
1162    /// record cannot be saved walks afresh (E586-L1).
1163    pub fn set_graph_budget_limbs(&mut self, max_bytes_fetched: u64, max_nodes: u32) {
1164        self.graph_budget_limbs = (max_bytes_fetched, max_nodes);
1165    }
1166
1167    /// Bound ONE transaction's GASP finalize submit (bsv-low #559, the delta
1168    /// lens's DELTA-3). That submit is the write section no deadline drops
1169    /// ([`Engine::finalize_submit_gate`]), so a storage call or lookup hook
1170    /// that never answers inside it held the per-peer budget and any outer
1171    /// race for as long as it hung. With this set EACH storage call, lookup
1172    /// hook and topic manager call of that submit is raced against one
1173    /// `sleep(budget_ms)` of the section (the lens fold of 2026-10-07, F2):
1174    /// a call still unanswered when it falls due is dropped alone and is
1175    /// that call's FAULT, and no call is started after it. The SUBMIT is
1176    /// never dropped: it runs to its end and leaves what a fault leaves
1177    /// ([`Engine::submit_with_report`]: nothing of the transaction, or all
1178    /// of it), the transaction did not land, the rest of its graph is not
1179    /// submitted, its UTXO fails and the cursor stays below it. The undo of
1180    /// a faulted submit has one more `sleep(budget_ms)` of its own, so a
1181    /// peer costs at most its budget plus twice this one. What the backend
1182    /// does with a statement whose caller stopped waiting is its own: a
1183    /// write that lands after its timeout is not seen by this submit.
1184    ///
1185    /// DEFAULT: none (a native engine has no clock). A caller that sets a
1186    /// per-peer budget should set this too.
1187    pub fn set_finalize_submit_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1188        self.finalize_submit_budget = Some((sleep, budget_ms));
1189    }
1190
1191    /// The gate around every GASP finalize submit (bsv-low #552, the lens
1192    /// fold's HIGH-1). `start_gasp_sync` races each peer against its budget
1193    /// with [`crate::gasp::race_or_deadline_guarded`] over this gate. A
1194    /// caller that races `start_gasp_sync` ITSELF against a deadline (the
1195    /// worker's scheduled step) must use the same function over the same
1196    /// gate: a plain `race_or_deadline` there can still drop a finalize
1197    /// submit between its writes.
1198    pub fn finalize_submit_gate(&self) -> &crate::gasp::SubmitGate {
1199        &self.finalize_gate
1200    }
1201
1202    /// Turn reference-parity script verification on submit on or off.
1203    /// **DEFAULT ON** (set in every constructor).
1204    ///
1205    /// On, every submit outside `HistoricalTxNoSpv` executes every unproven
1206    /// input's unlocking script against its source output (and checks every
1207    /// merkle path against the chain tracker), exactly as the reference's
1208    /// `Engine.submit` → `tx.verify(this.chainTracker)` does. Off is an
1209    /// ESCAPE HATCH for an operator who must admit a body the interpreter
1210    /// refuses (a bsv-rs interpreter defect, say) while it is fixed. It is
1211    /// NOT a mode: it restores the pre-2026-09-08 structural check, under
1212    /// which an invalid spend that no broadcaster had yet refused was
1213    /// admitted on BEEF structure alone. Prefer
1214    /// [`crate::builder::EngineBuilder::with_script_verification`].
1215    pub fn set_script_verification(&mut self, enabled: bool) {
1216        self.verify_scripts = enabled;
1217    }
1218
1219    /// Whether submits execute input scripts, see
1220    /// [`Engine::set_script_verification`]. `true` by default.
1221    pub fn script_verification(&self) -> bool {
1222        self.verify_scripts
1223    }
1224
1225    /// The budget of the submit's own script walk (bsv-low #592, the owner's
1226    /// ruling 3a of 2026-10-10). Every submit that walks (every mode but
1227    /// `HistoricalTxNoSpv`), and the GASP anchor check, read the BEEF through
1228    /// the door's stream walk ([`crate::script_door`]) under this budget's
1229    /// two limbs, the work and the memory, each estimated from the bytes
1230    /// before it is spent. A breach is "the walk could not run"
1231    /// ([`WalkCouldNotRun`], on [`MutationReport::walk_could_not_run`]),
1232    /// never a refusal: the submit goes on as under `HistoricalTxNoSpv`.
1233    /// A library consumer that sets nothing gets [`DoorBudget::DEFAULT`]
1234    /// (64 MiB of work, 48 MiB of memory). Prefer
1235    /// [`crate::builder::EngineBuilder::with_walk_budget`].
1236    pub fn set_walk_budget(&mut self, budget: DoorBudget) {
1237        self.walk_budget = budget;
1238    }
1239
1240    /// The budget of the submit's own script walk, see
1241    /// [`Engine::set_walk_budget`].
1242    pub fn walk_budget(&self) -> DoorBudget {
1243        self.walk_budget
1244    }
1245
1246    /// The reference's `tx.verify('scripts only')` over a BEEF: every unproven
1247    /// transaction from `subject_txid` down executes every input's unlocking
1248    /// script against its source output and obeys the value rule, while a
1249    /// transaction that carries a merkle path is TRUSTED AS-IS — no root is
1250    /// computed and no chain tracker is consulted (the ts-sdk's own shape when
1251    /// `chainTracker === 'scripts only'`: a proven tx is added to the verified
1252    /// set and the walk stops there).
1253    ///
1254    /// Built for an admission path whose bar is the NETWORK, not a proof
1255    /// (bsv-low's `broadcast-gated`, register row D1): the overlay broadcasts
1256    /// and admits on network evidence, so a lagging tracker must never read as
1257    /// a refused transaction — but a spend the interpreter refuses is one the
1258    /// network will refuse too, and executing it at the door saves the
1259    /// broadcast and names the fault. Independent of
1260    /// [`Engine::set_script_verification`] (that switch governs `submit`; this
1261    /// is an explicit ask) and of [`Engine::submit`]'s mode (`HistoricalTxNoSpv`
1262    /// still skips, as the reference does).
1263    ///
1264    /// Runs under the engine's budget ([`Engine::set_walk_budget`], the
1265    /// submit's own; [`DoorBudget::DEFAULT`] unless set: one budget per
1266    /// engine, the E592 lens fold's NOTE-2): the work is bounded statically
1267    /// before anything executes, and the interpreter's stack memory limit is
1268    /// the budget's element limit. The body is read through the streaming
1269    /// reader ([`crate::script_door`], bsv-low #585): no bound on its size,
1270    /// its transactions or a transaction's inputs or bytes; the budget's two
1271    /// limbs are the work and the memory beside the body.
1272    ///
1273    /// Errors: [`EngineError::ScriptVerificationFailed`] is the INTERPRETER's
1274    /// verdict and the only refusal; [`EngineError::ScriptWalkInconclusive`]
1275    /// is a structural fault (a transaction or source missing from the BEEF,
1276    /// a parse fault, the value rule) and [`EngineError::ScriptWalkOverBudget`]
1277    /// the door's own bound (the static budget or the memory limit) — both
1278    /// name whether the SUBJECT was judged before the fault, and a caller with
1279    /// a stronger bar behind it does not refuse on either.
1280    #[allow(
1281        clippy::unused_async,
1282        reason = "the door's callers await it on an engine; 'scripts only' asks no tracker"
1283    )]
1284    pub async fn verify_scripts_only(
1285        &self,
1286        beef_bytes: &[u8],
1287        subject_txid: &str,
1288    ) -> Result<WalkStats, EngineError> {
1289        crate::script_door::walk(beef_bytes, subject_txid, self.walk_budget)
1290    }
1291
1292    /// [`Engine::verify_scripts_only`] under a budget the caller names (the
1293    /// pins measure the door with a limb lifted; a caller with another
1294    /// platform's ceiling sets its own).
1295    #[allow(
1296        clippy::unused_async,
1297        clippy::unused_self,
1298        reason = "the twin of `verify_scripts_only`"
1299    )]
1300    pub async fn verify_scripts_only_under(
1301        &self,
1302        beef_bytes: &[u8],
1303        subject_txid: &str,
1304        budget: DoorBudget,
1305    ) -> Result<WalkStats, EngineError> {
1306        crate::script_door::walk(beef_bytes, subject_txid, budget)
1307    }
1308
1309    // ========================================================================
1310    // Submit — 3-phase pipeline
1311    // ========================================================================
1312
1313    /// Validate a tagged BEEF without applying mutations.
1314    ///
1315    /// Runs Phase 1 (topic validation) and Phase 2 (broadcast) but NOT Phase 3
1316    /// (storage mutations). Returns the Steak (admittance decisions) that
1317    /// `submit()` would return.
1318    ///
1319    /// Used by the onSteakReady pattern to return results to clients before
1320    /// mutations are applied via a queue consumer or `ctx.wait_until()`.
1321    ///
1322    /// The topic managers are called with `dry_run: true`
1323    /// (`TopicAdmittanceContext::DRY_RUN`): nothing is admitted on this call, so a
1324    /// manager that writes on admission leaves no trace of it.
1325    pub async fn submit_validate_only(
1326        &self,
1327        tagged_beef: &TaggedBEEF,
1328        mode: SubmitMode,
1329    ) -> Result<Steak, EngineError> {
1330        // A validate-only call admits nothing: a dry run by its own name. A
1331        // manager that writes on admission must not write here, or the real
1332        // submit that follows meets a head this call already advanced.
1333        let (_validations, steak, _tx, _txid, _could_not_run) = self
1334            .run_validation(
1335                tagged_beef,
1336                mode,
1337                &TopicAdmittanceContext::DRY_RUN,
1338                None,
1339                None,
1340                WalkBreachPolicy::NotNow,
1341            )
1342            .await?;
1343        Ok(steak)
1344    }
1345
1346    /// Submit a transaction for processing by the overlay.
1347    ///
1348    /// Three phases:
1349    /// 1. **VALIDATE** — check topics, dedup, find previous coins, call topic managers
1350    /// 2. **BROADCAST** — broadcast to network (if mode is CurrentTx)
1351    /// 3. **MUTATE** — mark spent, delete stale, insert new outputs, notify lookup services
1352    ///
1353    /// Returns a STEAK mapping each topic to its admittance instructions.
1354    ///
1355    /// Phase-3 faults are reported through [`Engine::submit_with_report`];
1356    /// this convenience keeps the historical signature and only LOGS the
1357    /// report's summary. A caller that must not ack an admission whose
1358    /// writes did not land (the `/submit` route — S2) uses the report form.
1359    pub async fn submit(
1360        &self,
1361        tagged_beef: &TaggedBEEF,
1362        mode: SubmitMode,
1363    ) -> Result<Steak, EngineError> {
1364        let (steak, report) = self.submit_with_report(tagged_beef, mode).await?;
1365        if !report.is_durable() {
1366            warn!(
1367                "submit: {} mutation fault(s) — not durable: {}",
1368                report.faults.len(),
1369                report.summary()
1370            );
1371        }
1372        Ok(steak)
1373    }
1374
1375    /// [`Engine::submit`] plus the Phase-3 durability report (S2
1376    /// queue-durable admission, bsv-low 2026-08-29).
1377    ///
1378    /// The STEAK is the admission DECISION; the report says whether the
1379    /// decision was DURABLY WRITTEN. They are returned together because
1380    /// they are answered by different things: Phase 1+2 decide, Phase 3
1381    /// lands, and only the caller knows whether an undurable landing may
1382    /// be acked (never, on the money path — re-queue it instead).
1383    ///
1384    /// Every write failure is still logged AND still non-fatal to the
1385    /// other topics (a faulted topic does not un-admit its siblings); what
1386    /// changed is that the failure is also VISIBLE, and that the faulted
1387    /// topic is not recorded as applied — so a replay of the same bytes is
1388    /// re-validated and re-written (every backend write is idempotent:
1389    /// `INSERT OR IGNORE` / `OR REPLACE` / `UPDATE`).
1390    ///
1391    /// What a fault leaves (bsv-low #559 and its lens fold of 2026-10-07):
1392    /// a topic's previous coins are deleted once EVERY admitted output of
1393    /// the transaction is inserted, and only then. A fault before that
1394    /// (a validation read, the spent mark of a coin the manager retains, an
1395    /// insert) leaves the previous coins held and nothing of the
1396    /// transaction: what did get inserted is taken out again, so the replay
1397    /// is the same judgement and no successor can spend half of it. A fault
1398    /// after it (a lookup notification, the consumed-by update, the applied
1399    /// row) leaves the transaction's outputs and no previous coin. A fault
1400    /// before any delete was started (the record of the spent coin's
1401    /// transaction, the read of the coin) is the first case if the coins
1402    /// are read back still held; once a delete was started its fault never
1403    /// undoes, answered or not (it may land yet): the outputs stay beside
1404    /// the stale coin, marked spent. A submit that deletes a stale coin
1405    /// first records that coin's transaction as applied, so a replay of it
1406    /// after its successor is a dupe. So a non-retaining chain never has
1407    /// two unspent heads and never none, whatever single call faults. The
1408    /// applied row is withheld on ANY fault. And a successor that was
1409    /// judged without the coin an unlanded predecessor has yet to leave is
1410    /// reported as a fault (`predecessor_not_landed`), not recorded as
1411    /// applied.
1412    ///
1413    /// A walk that could not run within the engine's budget is "not now"
1414    /// here ([`WalkBreachPolicy::NotNow`], [`EngineError::WalkCouldNotRun`]):
1415    /// never an admission. [`Engine::submit_with_report_under`] names the
1416    /// policy.
1417    pub async fn submit_with_report(
1418        &self,
1419        tagged_beef: &TaggedBEEF,
1420        mode: SubmitMode,
1421    ) -> Result<(Steak, MutationReport), EngineError> {
1422        self.submit_with_report_under(tagged_beef, mode, WalkBreachPolicy::NotNow)
1423            .await
1424    }
1425
1426    /// [`Engine::submit_with_report`] under an explicit
1427    /// [`WalkBreachPolicy`] (the E592 lens fold, H1): what a walk that could
1428    /// not run within the budget does. `NetworkAccepted` is for a caller that
1429    /// holds the network's accept of these very bytes (the queue's replay of
1430    /// a gated submission) and for nothing else: it admits a breached body
1431    /// with its scripts unrun.
1432    pub async fn submit_with_report_under(
1433        &self,
1434        tagged_beef: &TaggedBEEF,
1435        mode: SubmitMode,
1436        policy: WalkBreachPolicy,
1437    ) -> Result<(Steak, MutationReport), EngineError> {
1438        self.submit_bounded(tagged_beef, mode, None, false, policy)
1439            .await
1440    }
1441
1442    /// [`Engine::submit_with_report`] with every storage call, lookup hook
1443    /// and topic manager call under `bound` (the GASP finalize submit,
1444    /// [`CallBound`]); `None` is the unbounded submit of every other door.
1445    /// `finalize` is `true` for the GASP finalize submit alone, bounded or
1446    /// not: it does not ask the store's predecessor question.
1447    async fn submit_bounded(
1448        &self,
1449        tagged_beef: &TaggedBEEF,
1450        mode: SubmitMode,
1451        bound: Option<&CallBound>,
1452        finalize: bool,
1453        policy: WalkBreachPolicy,
1454    ) -> Result<(Steak, MutationReport), EngineError> {
1455        // The reads the store's predecessor question has made in this
1456        // submit, every topic together ([`PREDECESSOR_READS_PER_SUBMIT`]),
1457        // the predecessors the door lands first from the BEEF included.
1458        let mut question_reads = 0usize;
1459        self.submit_counted(
1460            tagged_beef,
1461            mode,
1462            bound,
1463            finalize,
1464            None,
1465            &mut question_reads,
1466            policy,
1467        )
1468        .await
1469        .map(|(steak, report, _)| (steak, report))
1470    }
1471
1472    /// [`Engine::submit_bounded`] over the submit's read allowance
1473    /// (`question_reads`), which a predecessor the door lands first from the
1474    /// BEEF shares with the successor that carried it (lane E1D's lens fold,
1475    /// M1). `carried` is `Some` for such a predecessor's own submit
1476    /// ([`Engine::land_carried`]), holding the bodies whose SPV walk already
1477    /// passed in this submit (none is walked twice; the delta fold, L4): it
1478    /// lands nothing first itself, and it names instead the carried
1479    /// predecessor that blocks each topic (the third value), so the landing
1480    /// is an explicit stack and never a recursion of submits (nested polls
1481    /// grow the stack, and a Worker's is small). Its manager is asked as a
1482    /// DRY RUN first, and for real only once its topic is not blocked: a
1483    /// link blocked below is judged twice and admitted once. Boxed: the
1484    /// landing is a submit of its own.
1485    #[allow(clippy::too_many_arguments)]
1486    fn submit_counted<'a>(
1487        &'a self,
1488        tagged_beef: &'a TaggedBEEF,
1489        mode: SubmitMode,
1490        bound: Option<&'a CallBound>,
1491        finalize: bool,
1492        carried: Option<&'a HashSet<String>>,
1493        question_reads: &'a mut usize,
1494        policy: WalkBreachPolicy,
1495    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Counted, EngineError>> + 'a>>
1496    {
1497        Box::pin(async move {
1498            self.submit_counted_inner(
1499                tagged_beef,
1500                mode,
1501                bound,
1502                finalize,
1503                carried,
1504                question_reads,
1505                policy,
1506            )
1507            .await
1508        })
1509    }
1510
1511    #[allow(clippy::too_many_arguments)]
1512    async fn submit_counted_inner(
1513        &self,
1514        tagged_beef: &TaggedBEEF,
1515        mode: SubmitMode,
1516        bound: Option<&CallBound>,
1517        finalize: bool,
1518        walked: Option<&HashSet<String>>,
1519        question_reads: &mut usize,
1520        policy: WalkBreachPolicy,
1521    ) -> Result<Counted, EngineError> {
1522        // A submit is a real admission, never a dry run; a carried
1523        // predecessor's is judged dry until its topic is known not to wait.
1524        let carried = walked.is_some();
1525        let context = if carried {
1526            TopicAdmittanceContext::DRY_RUN
1527        } else {
1528            TopicAdmittanceContext::default()
1529        };
1530        let (mut validations, mut steak, tx, txid, could_not_run) = self
1531            .run_validation(tagged_beef, mode, &context, bound, walked, policy)
1532            .await?;
1533        let mut report = MutationReport {
1534            walk_could_not_run: could_not_run,
1535            ..MutationReport::default()
1536        };
1537        // The body every LOOKUP SERVICE receives NAMES the subject (BRC-95
1538        // atomic prefix over the WHOLE submitted body — bsv-rs's
1539        // `to_binary_atomic` sorts, names, never prunes). Lookup services
1540        // re-parse it with `from_beef(_, None)`; on a plain incomplete body
1541        // that pick is the wire-last ANCESTOR, so the pot index's admit hook
1542        // shape-checked a hop and wrote no record while the engine had
1543        // admitted the pot (loop-2 fleet, 2026-09-05; the live proof after
1544        // the engine's own subject fix). A body that cannot be re-serialized
1545        // (never, for a parsed subject) is handed through as submitted.
1546        let subject_named_beef: Vec<u8> =
1547            beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
1548                .ok()
1549                .and_then(|mut b| b.to_binary_atomic(&txid).ok())
1550                .unwrap_or_else(|| tagged_beef.beef.clone());
1551
1552        // The successor rule, asked of every topic that found no coin before
1553        // anything of this submit is written ([`Engine::successors_waiting`]):
1554        // the topics that must wait, each with why.
1555        let mut blocked: HashMap<String, String> = HashMap::new();
1556        // The landing trusts the subject's walk only if it RAN (the E592
1557        // lens fold, M1): after a breach nothing it reaches was walked.
1558        let landing = Landing {
1559            policy,
1560            subject_walk_ran: report.walk_could_not_run.is_none(),
1561        };
1562        let waits = self
1563            .successors_waiting(
1564                &tx,
1565                &txid,
1566                &subject_named_beef,
1567                &mut validations,
1568                &mut steak,
1569                tagged_beef.off_chain_values.as_deref(),
1570                mode,
1571                bound,
1572                finalize,
1573                !carried,
1574                &mut blocked,
1575                question_reads,
1576                &mut report.landed_predecessors,
1577                landing,
1578                &mut report.landed_walks_could_not_run,
1579            )
1580            .await;
1581        // A carried predecessor's topic that does not wait is judged again,
1582        // for real: what it admits is this call's word, over the same coins.
1583        if carried {
1584            for v in &mut validations {
1585                if v.is_dupe || v.failed || v.read_fault.is_some() || waits.contains_key(&v.topic) {
1586                    continue;
1587                }
1588                match self
1589                    .judge(
1590                        &tx,
1591                        &v.topic,
1592                        &v.previous_coins,
1593                        tagged_beef.off_chain_values.as_deref(),
1594                        mode,
1595                        &TopicAdmittanceContext::default(),
1596                        bound,
1597                    )
1598                    .await
1599                {
1600                    Ok(admittance) => v.admittance = admittance,
1601                    Err(e) => {
1602                        error!("Error validating topic {} during submit: {e}", v.topic);
1603                        v.failed = true;
1604                        v.previous_coins.clear();
1605                        v.previous_outputs.clear();
1606                        v.admittance = AdmittanceInstructions::default();
1607                    }
1608                }
1609                steak.insert(v.topic.clone(), v.admittance.clone());
1610            }
1611        }
1612
1613        // =================================================================
1614        // PHASE 3: MUTATE STORAGE
1615        // =================================================================
1616        for v in &validations {
1617            if v.is_dupe {
1618                report.deduped_topics.push(v.topic.clone());
1619                continue;
1620            }
1621            // A failed topic comes first, a read fault of it included: a
1622            // previous-coin read that faulted and a manager that then ERRED
1623            // is a failed topic with a durable report, no fault and no
1624            // replay. Parity: the reference fails the topic and never
1625            // replays. Nothing was written, so the store is as it was.
1626            if v.failed {
1627                continue;
1628            }
1629
1630            let topic = &v.topic;
1631            let admittance = &v.admittance;
1632            let faults_before = report.faults.len();
1633
1634            // A validation read that faulted: the manager judged without a
1635            // coin the store may hold, so NOTHING is written on that
1636            // judgement (the lens fold of 2026-10-07). Writing its outputs
1637            // would leave them beside the unseen coin, for a successor to
1638            // spend before the replay deletes it.
1639            if let Some((read, err)) = &v.read_fault {
1640                report.fault(topic, read, err.clone());
1641                self.hold_unapplied(&txid, topic, "a validation read faulted, nothing written");
1642                continue;
1643            }
1644
1645            // bsv-low #559 and lane E1D: a topic that found no coin while a
1646            // predecessor's landing is unknown is "not now", recorded
1647            // nowhere ([`Engine::successors_waiting`]).
1648            if let Some(not_now) = waits.get(topic) {
1649                report.fault(topic, "predecessor_not_landed", not_now.clone());
1650                self.hold_unapplied(
1651                    &txid,
1652                    topic,
1653                    &format!("found no coin and its predecessor {not_now}"),
1654                );
1655                continue;
1656            }
1657
1658            // ── Handle stale vs retained previous coins ──
1659            let mut outputs_consumed: Vec<Outpoint> = Vec::new();
1660            let mut stale_coins: Vec<&Output> = Vec::new();
1661
1662            for (coin_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1663                let input_index = v.previous_coins[coin_idx];
1664                if admittance.coins_to_retain.contains(&input_index) {
1665                    // Retained: track as consumed (will update consumedBy later)
1666                    outputs_consumed
1667                        .push(Outpoint::new(&prev_output.txid, prev_output.output_index));
1668                } else {
1669                    // Not retained: mark as stale for deletion
1670                    stale_coins.push(prev_output);
1671                }
1672            }
1673
1674            // Update STEAK with removed coins
1675            if let Some(steak_entry) = steak.get_mut(topic) {
1676                steak_entry.coins_removed = Some(
1677                    stale_coins
1678                        .iter()
1679                        .enumerate()
1680                        .filter_map(|(i, _)| {
1681                            // Map back to input indices
1682                            v.previous_coins.get(i).copied()
1683                        })
1684                        .collect(),
1685                );
1686            }
1687
1688            // ── Mark previous outputs as spent + notify lookup services ──
1689            // A mark that faults does not skip the notification (the
1690            // reference skips it): the coin is spent whatever the row says,
1691            // a stale one is deleted below and its lookup services must
1692            // know. The mark of a RETAINED coin is part of what landed: that
1693            // row stays, and left unspent it would be listed as a UTXO
1694            // beside the output that consumed it.
1695            let mut retained_mark_faulted = false;
1696            for (prev_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1697                if let Err(e) = stored(
1698                    bound,
1699                    self.storage.mark_utxo_as_spent(
1700                        &prev_output.txid,
1701                        prev_output.output_index,
1702                        topic,
1703                    ),
1704                )
1705                .await
1706                {
1707                    error!("Error marking UTXO as spent: {e}");
1708                    report.fault(topic, "mark_utxo_as_spent", e.to_string());
1709                    retained_mark_faulted |= admittance
1710                        .coins_to_retain
1711                        .contains(&v.previous_coins[prev_idx]);
1712                }
1713
1714                // The input index within tx.inputs that spends this previous output.
1715                let spending_input_idx = v.previous_coins[prev_idx] as usize;
1716
1717                // Notify all lookup services about the spent output
1718                for ls in self.lookup_services.values() {
1719                    let payload = match ls.spend_notification_mode() {
1720                        SpendNotificationMode::None => OutputSpent::None {
1721                            txid: prev_output.txid.clone(),
1722                            output_index: prev_output.output_index,
1723                            topic: topic.clone(),
1724                        },
1725                        SpendNotificationMode::Txid => OutputSpent::Txid {
1726                            txid: prev_output.txid.clone(),
1727                            output_index: prev_output.output_index,
1728                            topic: topic.clone(),
1729                            spending_txid: txid.clone(),
1730                        },
1731                        SpendNotificationMode::Script => {
1732                            // Extract unlocking script and sequence from the spending input
1733                            let input = &tx.inputs[spending_input_idx];
1734                            let unlocking_script = input
1735                                .unlocking_script
1736                                .as_ref()
1737                                .map(bsv_rs::UnlockingScript::to_binary)
1738                                .unwrap_or_default();
1739                            OutputSpent::Script {
1740                                txid: prev_output.txid.clone(),
1741                                output_index: prev_output.output_index,
1742                                topic: topic.clone(),
1743                                spending_txid: txid.clone(),
1744                                input_index: spending_input_idx as u32,
1745                                unlocking_script,
1746                                sequence_number: input.sequence,
1747                                off_chain_values: tagged_beef.off_chain_values.clone(),
1748                            }
1749                        }
1750                        SpendNotificationMode::WholeTx => OutputSpent::WholeTx {
1751                            txid: prev_output.txid.clone(),
1752                            output_index: prev_output.output_index,
1753                            topic: topic.clone(),
1754                            spending_atomic_beef: subject_named_beef.clone(),
1755                            off_chain_values: tagged_beef.off_chain_values.clone(),
1756                        },
1757                    };
1758                    if let Err(e) = hooked(bound, ls.output_spent(&payload)).await {
1759                        error!("Error notifying lookup service of spent output: {e}");
1760                        report.fault(topic, "lookup_service.output_spent", e);
1761                    }
1762                }
1763            }
1764            if retained_mark_faulted {
1765                self.hold_unapplied(
1766                    &txid,
1767                    topic,
1768                    "the spent mark of a retained coin faulted, nothing inserted",
1769                );
1770                continue;
1771            }
1772
1773            // ── Insert admitted outputs ──
1774            // The inserts stop at the first one that faults: what follows is
1775            // the undo, not the rest of the transaction.
1776            let mut new_utxos: Vec<Outpoint> = Vec::new();
1777            let mut attempted: Vec<u32> = Vec::new();
1778            let mut insert_faulted = false;
1779
1780            for &output_index in &admittance.outputs_to_admit {
1781                let (script_bytes, sats) =
1782                    if let Some(tx_output) = tx.outputs.get(output_index as usize) {
1783                        (
1784                            tx_output.locking_script.to_binary(),
1785                            tx_output.get_satoshis(),
1786                        )
1787                    } else {
1788                        (Vec::new(), 0)
1789                    };
1790
1791                let output = Output {
1792                    txid: txid.clone(),
1793                    output_index,
1794                    output_script: script_bytes,
1795                    satoshis: sats,
1796                    topic: topic.clone(),
1797                    spent: false,
1798                    outputs_consumed: outputs_consumed.clone(),
1799                    consumed_by: Vec::new(),
1800                    beef: Some(tagged_beef.beef.clone()),
1801                    block_height: None,
1802                    score: Some(current_timestamp_ms()),
1803                };
1804
1805                attempted.push(output_index);
1806                if let Err(e) = stored(bound, self.storage.insert_output(&output)).await {
1807                    error!("Error inserting output for topic {topic}: {e}");
1808                    report.fault(topic, "insert_output", e.to_string());
1809                    insert_faulted = true;
1810                    break;
1811                }
1812
1813                new_utxos.push(Outpoint::new(&txid, output_index));
1814
1815                // Notify lookup services
1816                for ls in self.lookup_services.values() {
1817                    let (ls_script, ls_sats) =
1818                        if let Some(tx_out) = tx.outputs.get(output_index as usize) {
1819                            (tx_out.locking_script.to_binary(), tx_out.get_satoshis())
1820                        } else {
1821                            (Vec::new(), 0)
1822                        };
1823
1824                    let payload = match ls.admission_mode() {
1825                        AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
1826                            txid: txid.clone(),
1827                            output_index,
1828                            topic: topic.clone(),
1829                            satoshis: ls_sats,
1830                            locking_script: ls_script,
1831                            off_chain_values: tagged_beef.off_chain_values.clone(),
1832                        },
1833                        AdmissionMode::WholeTx => OutputAdmittedByTopic::WholeTx {
1834                            atomic_beef: subject_named_beef.clone(),
1835                            output_index,
1836                            topic: topic.clone(),
1837                            off_chain_values: tagged_beef.off_chain_values.clone(),
1838                        },
1839                    };
1840
1841                    if let Err(e) = hooked(bound, ls.output_admitted_by_topic(&payload)).await {
1842                        error!("Error notifying lookup service: {e}");
1843                        report.fault(topic, "lookup_service.output_admitted_by_topic", e);
1844                    }
1845                }
1846            }
1847
1848            // An insert faulted: the transaction did not land, so NOTHING of
1849            // it stays (the lens fold of 2026-10-07, F1). The previous coins
1850            // are kept (#559: with the delete first, one faulted insert left
1851            // a non-retaining head chain with NO head) and the outputs that
1852            // were inserted are taken out again, the faulted one included (a
1853            // backend's insert can fault after its row landed). Left in,
1854            // they could be spent and deleted by a successor before the
1855            // replay, and the replay, which still finds the previous coin,
1856            // would insert them a second time: a spent head back among the
1857            // UTXOs for good.
1858            if insert_faulted {
1859                if let Some(bound) = bound {
1860                    bound.renew();
1861                }
1862                self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
1863                    .await;
1864                self.hold_unapplied(
1865                    &txid,
1866                    topic,
1867                    &format!(
1868                        "an insert faulted: {} previous coin(s) kept, nothing of it held",
1869                        stale_coins.len()
1870                    ),
1871                );
1872                continue;
1873            }
1874
1875            // ── Delete stale outputs recursively ──
1876            // AFTER the inserts, and once every one of them landed (bsv-low
1877            // #559 and the lens fold of 2026-10-07, F1). The reference
1878            // deletes first (`applyTopicStorageMutation`: `removeStaleOutputs`,
1879            // then `admitOutput`), catches a throw per topic and never
1880            // replays; here a fault is survived and replayed, so the order
1881            // is ours, an addition. The delete does NOT wait for a clean
1882            // report (c9921ee did, and one faulted mark or notification
1883            // after the insert then left the stale coin beside the new
1884            // output for good: two unspent heads): a fault of the mark, of
1885            // a notification, deletes as the reference does.
1886            //
1887            // Before a stale coin is deleted its transaction is recorded as
1888            // applied in the topic (the delta fold of 2026-10-07, H2; an
1889            // addition, one idempotent write per deleted coin). A held coin
1890            // proves the inserts of its transaction landed, but that
1891            // transaction may have no applied row (a fault after its
1892            // inserts withheld it, or its insert landed after its timeout),
1893            // and then its replay is judged again: a manager that admits it
1894            // with NO previous coin (the opener of a chain) had it inserted
1895            // a second time, unspent, beside the output that spent it, for
1896            // good. With the row its replay is a dupe. A record that faults
1897            // leaves its coin undeleted: that is a delete fault, below.
1898            let due_before_the_delete = bound.is_some_and(CallBound::is_due);
1899            let mut delete_faulted = false;
1900            let mut delete_started = false;
1901            for stale in &stale_coins {
1902                let spent_coin_tx = AppliedTransaction {
1903                    txid: stale.txid.clone(),
1904                    topic: topic.clone(),
1905                };
1906                if let Err(e) = stored(
1907                    bound,
1908                    self.storage.insert_applied_transaction(&spent_coin_tx),
1909                )
1910                .await
1911                {
1912                    error!(
1913                        "Error recording {} as applied before its coin is deleted from topic {topic}: {e}",
1914                        stale.txid
1915                    );
1916                    report.fault(topic, "record_spent_coin_applied", e.to_string());
1917                    delete_faulted = true;
1918                    continue;
1919                }
1920                self.not_landed
1921                    .borrow_mut()
1922                    .remove(&(stale.txid.clone(), topic.clone()));
1923                match stored(
1924                    bound,
1925                    self.storage.find_output(
1926                        &stale.txid,
1927                        stale.output_index,
1928                        Some(topic),
1929                        None,
1930                        false,
1931                    ),
1932                )
1933                .await
1934                {
1935                    Ok(Some(stale_output)) => {
1936                        delete_started = true;
1937                        if let Err(e) = self.delete_utxo_deep(&stale_output, bound).await {
1938                            error!("Error deleting stale output for topic {topic}: {e}");
1939                            report.fault(topic, "delete_utxo_deep", e.to_string());
1940                            delete_faulted = true;
1941                        }
1942                    }
1943                    Ok(None) => {}
1944                    Err(e) => {
1945                        error!("Error reading stale output for topic {topic}: {e}");
1946                        report.fault(topic, "find_output", e.to_string());
1947                        delete_faulted = true;
1948                    }
1949                }
1950            }
1951
1952            // A fault here, and what it leaves. The store is put back as it
1953            // was found (the inserts undone, the replay does the whole
1954            // transaction) only when NO delete was started: the fault is of
1955            // H2's record or of the read before the delete, the call
1956            // answered, and EVERY stale coin is read back still held.
1957            //
1958            // Once a delete was STARTED nothing is undone, whether it
1959            // answered or not (the delta fold of 2026-10-07, H1, for a call
1960            // that did not answer; the second delta fold, M1, for one that
1961            // answered an error). A statement can land after its caller was
1962            // told it failed, as after its caller stopped waiting: the coin
1963            // is read back held now and is deleted a moment later, and the
1964            // undo then left the chain with no head, for good, for a manager
1965            // that needs the previous coin. So the outputs stay, the stale
1966            // coin stays (marked spent) until its delete lands or is done
1967            // again, and there is no applied row. Since H2 every order from
1968            // there converges. The replay first, the delete never landed:
1969            // the coin is found, the manager admits again, the inserts are
1970            // no-ops, the delete is done and the row written. The replay
1971            // first, the delete landed late: no previous coin, so a manager
1972            // that needs one admits nothing and the transaction is recorded
1973            // with its outputs held. A successor first: it recorded this
1974            // transaction as applied (H2) and the replay is a dupe; the
1975            // stale coin's spent row is then left behind, no UTXO.
1976            //
1977            // A call that did not ANSWER before any delete (the record, the
1978            // read) undoes nothing either: it is not known what it did. A
1979            // deadline that was already due BEFORE the first of these calls
1980            // started nothing here, so the coins are certainly held and the
1981            // store is put back.
1982            if delete_faulted {
1983                let unanswered = !due_before_the_delete && bound.is_some_and(CallBound::is_due);
1984                if let Some(bound) = bound {
1985                    bound.renew();
1986                }
1987                let mut all_held = !unanswered && !delete_started;
1988                for stale in &stale_coins {
1989                    if !all_held {
1990                        break;
1991                    }
1992                    let read = stored(
1993                        bound,
1994                        self.storage.find_output(
1995                            &stale.txid,
1996                            stale.output_index,
1997                            Some(topic),
1998                            None,
1999                            false,
2000                        ),
2001                    )
2002                    .await;
2003                    if !matches!(read, Ok(Some(_))) {
2004                        all_held = false;
2005                        break;
2006                    }
2007                }
2008                if all_held {
2009                    self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
2010                        .await;
2011                }
2012                self.hold_unapplied(
2013                    &txid,
2014                    topic,
2015                    if all_held {
2016                        "no delete of a stale coin was started: the coins are held, its outputs taken out"
2017                    } else if unanswered {
2018                        "the delete of a stale coin did not answer: nothing undone, its outputs stay"
2019                    } else if delete_started {
2020                        "the delete of a stale coin faulted: nothing undone, its outputs stay"
2021                    } else {
2022                        "no delete was started and a stale coin was not read back held: nothing undone, its outputs stay"
2023                    },
2024                );
2025                continue;
2026            }
2027
2028            // ── Update consumedBy on retained previous outputs ──
2029            for consumed_outpoint in &outputs_consumed {
2030                let consumed_output = match stored(
2031                    bound,
2032                    self.storage.find_output(
2033                        &consumed_outpoint.txid,
2034                        consumed_outpoint.output_index,
2035                        Some(topic),
2036                        None,
2037                        false,
2038                    ),
2039                )
2040                .await
2041                {
2042                    Ok(Some(o)) => o,
2043                    Ok(None) => continue,
2044                    Err(e) => {
2045                        error!("Error reading consumed output for topic {topic}: {e}");
2046                        report.fault(topic, "find_output", e.to_string());
2047                        continue;
2048                    }
2049                };
2050                {
2051                    let mut new_consumed_by = consumed_output.consumed_by.clone();
2052                    for new_utxo in &new_utxos {
2053                        if !new_consumed_by.iter().any(|c| {
2054                            c.txid == new_utxo.txid && c.output_index == new_utxo.output_index
2055                        }) {
2056                            new_consumed_by.push(new_utxo.clone());
2057                        }
2058                    }
2059                    if let Err(e) = stored(
2060                        bound,
2061                        self.storage.update_consumed_by(
2062                            &consumed_outpoint.txid,
2063                            consumed_outpoint.output_index,
2064                            topic,
2065                            &new_consumed_by,
2066                        ),
2067                    )
2068                    .await
2069                    {
2070                        error!("Error updating consumedBy: {e}");
2071                        report.fault(topic, "update_consumed_by", e.to_string());
2072                    }
2073                }
2074            }
2075
2076            // Record applied transaction — ONLY when every write above
2077            // landed. A faulted topic stays unrecorded so the Phase-1 dedup
2078            // cannot turn its replay into a no-op (S2).
2079            if report.faults.len() > faults_before {
2080                self.hold_unapplied(
2081                    &txid,
2082                    topic,
2083                    &format!(
2084                        "{} mutation fault(s); a replay of these bytes re-applies",
2085                        report.faults.len() - faults_before
2086                    ),
2087                );
2088                continue;
2089            }
2090            let tx_record = AppliedTransaction {
2091                txid: txid.clone(),
2092                topic: topic.clone(),
2093            };
2094            match stored(bound, self.storage.insert_applied_transaction(&tx_record)).await {
2095                Ok(()) => {
2096                    report.applied_topics.push(topic.clone());
2097                    self.not_landed
2098                        .borrow_mut()
2099                        .remove(&(txid.clone(), topic.clone()));
2100                }
2101                Err(e) => {
2102                    error!("Error inserting applied transaction for topic {topic}: {e}");
2103                    report.fault(topic, "insert_applied_transaction", e.to_string());
2104                    self.not_landed
2105                        .borrow_mut()
2106                        .insert((txid.clone(), topic.clone()));
2107                }
2108            }
2109        }
2110
2111        Ok((steak, report, blocked))
2112    }
2113
2114    /// The successor rule (bsv-low #559; lane E1D, bsv-low #575; and its
2115    /// lens fold of 2026-10-08), asked of every topic of a submit that found
2116    /// NO previous coin while the transaction spends something, before
2117    /// anything of the submit is written. Returns the topics that must wait,
2118    /// each with why: those are reported as a fault (`predecessor_not_landed`)
2119    /// and recorded nowhere, "not now". An addition to the reference, which
2120    /// records every non-failed topic.
2121    ///
2122    /// Such a topic was judged without the coin a predecessor has yet to
2123    /// leave. Recorded as applied, its replay was a dupe for good: a
2124    /// transaction that admits nothing ended the chain at the predecessor
2125    /// (#559), and one the manager admits WITHOUT its coin (the pf head
2126    /// manager over its own head state, E1D) was inserted, and the
2127    /// predecessor, landing later, stood unspent beside the tip.
2128    ///
2129    /// "Has not landed" is known two ways: this engine saw it fault
2130    /// (`not_landed`, one invocation, every door), or the store says so
2131    /// ([`Engine::unlanded_predecessor`], any invocation, never at a GASP
2132    /// finalize submit: its graph passed the anchor check, every parent
2133    /// inside the graph was submitted just before it and the sequence stops
2134    /// at the first that does not land; the second delta fold of 2026-10-07,
2135    /// M2). For a successor that admits something, both look only at the
2136    /// inputs the manager NAMES as its history (`identify_needed_inputs`
2137    /// over the subject-named BEEF; the lens fold, L1): a manager that
2138    /// names nothing (all 16 of this workspace) asks nothing there, as in
2139    /// the reference. A successor that admits NOTHING is asked of every
2140    /// input whatever its manager names (D17 (a)), so under such a manager
2141    /// it still waits for, or lands first, a carried body that spends a
2142    /// coin the topic holds: there the store differs from the reference's
2143    /// (the E1D delta fold, L3).
2144    ///
2145    /// The door lands a carried predecessor FIRST (the lens fold, M1; the
2146    /// GASP walk's rule, at the door): when the question names an unlanded
2147    /// predecessor whose body the BEEF carries, that body is submitted on
2148    /// its own to this topic ([`Engine::land_carried`]), under the submit's
2149    /// read allowance, and once it lands the topic is judged again
2150    /// ([`Engine::validate_topic`]) and asked again. The re-judgement's
2151    /// reads (the applied row, one coin per input) are charged to that
2152    /// allowance BEFORE the landing (the E1D delta fold, M1): a landing whose
2153    /// re-judgement would not fit is not made, and the topic waits. So one
2154    /// submit reads at most its own validation and writes plus
2155    /// [`PREDECESSOR_READS_PER_SUBMIT`], landings (on their clean path) and
2156    /// re-judgements included; a faulted landing's read-backs and undo, and
2157    /// a deep delete's walk of retained history, are not charged.
2158    /// Nothing is landed into a topic whose manager or a lookup
2159    /// service reads off-chain values (the delta fold, L1: the landing has
2160    /// none to give, and the predecessor's own submit would be a dupe): the
2161    /// topic waits for that submit. The first predecessor that does not land
2162    /// ends it: the topic waits. A predecessor whose body the BEEF does not
2163    /// carry is never landed here: its successor converges only when it
2164    /// lands (its own replay, a GASP peer, a resubmit).
2165    #[allow(clippy::too_many_arguments)]
2166    async fn successors_waiting(
2167        &self,
2168        tx: &Transaction,
2169        txid: &str,
2170        subject_named_beef: &[u8],
2171        validations: &mut [TopicValidation],
2172        steak: &mut Steak,
2173        off_chain_values: Option<&[u8]>,
2174        mode: SubmitMode,
2175        bound: Option<&CallBound>,
2176        finalize: bool,
2177        lands: bool,
2178        blocked: &mut HashMap<String, String>,
2179        question_reads: &mut usize,
2180        landed: &mut Vec<(String, String)>,
2181        landing: Landing,
2182        landed_breaches: &mut Vec<WalkCouldNotRun>,
2183    ) -> HashMap<String, String> {
2184        let mut waits = HashMap::new();
2185        let spent: Vec<(String, u32)> = tx
2186            .inputs
2187            .iter()
2188            .filter_map(|input| {
2189                let source = input.get_source_txid().ok()?;
2190                (!source.is_empty()).then_some((source, input.source_output_index))
2191            })
2192            .collect();
2193        if spent.is_empty() {
2194            return waits;
2195        }
2196        let found_no_coin = |v: &TopicValidation| {
2197            !v.is_dupe && !v.failed && v.read_fault.is_none() && v.previous_outputs.is_empty()
2198        };
2199        for v in validations.iter_mut() {
2200            if !found_no_coin(v) {
2201                continue;
2202            }
2203            let Some(manager) = self.managers.get(&v.topic) else {
2204                continue;
2205            };
2206            let topic = v.topic.clone();
2207            // The manager's word on the subject's history, asked at most
2208            // once per topic: up front for a successor that admits, by the
2209            // question when it needs it for one that does not.
2210            let mut subject_names: Option<HashSet<String>> = None;
2211            loop {
2212                let admits = !v.admittance.outputs_to_admit.is_empty();
2213                if admits && subject_names.is_none() {
2214                    subject_names = Some(
2215                        self.named_history(manager.as_ref(), subject_named_beef, bound)
2216                            .await,
2217                    );
2218                }
2219                let remembered = spent.iter().find(|(source, vout)| {
2220                    (!admits
2221                        || subject_names
2222                            .as_ref()
2223                            .is_some_and(|named| named.contains(&format!("{source}.{vout}"))))
2224                        && self
2225                            .not_landed
2226                            .borrow()
2227                            .contains(&(source.clone(), topic.clone()))
2228                });
2229                if let Some((predecessor, _)) = remembered {
2230                    waits.insert(topic.clone(), format!("{predecessor} has not landed"));
2231                    break;
2232                }
2233                if finalize {
2234                    break;
2235                }
2236                let Some(not_now) = self
2237                    .unlanded_predecessor(
2238                        tx,
2239                        txid,
2240                        subject_named_beef,
2241                        &topic,
2242                        mode,
2243                        admits,
2244                        &mut subject_names,
2245                        question_reads,
2246                        bound,
2247                    )
2248                    .await
2249                else {
2250                    break;
2251                };
2252                let Some(carried) = not_now.carried else {
2253                    waits.insert(topic.clone(), not_now.why);
2254                    break;
2255                };
2256                if !lands {
2257                    blocked.insert(topic.clone(), carried);
2258                    waits.insert(topic.clone(), not_now.why);
2259                    break;
2260                }
2261                if !self.lands_without_values(&topic) {
2262                    waits.insert(
2263                        topic.clone(),
2264                        format!(
2265                            "{}; 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",
2266                            not_now.why
2267                        ),
2268                    );
2269                    break;
2270                }
2271                // The re-judgement after the landing, charged before it.
2272                let rejudge = 1 + spent.len();
2273                if *question_reads + rejudge > PREDECESSOR_READS_PER_SUBMIT {
2274                    waits.insert(
2275                        topic.clone(),
2276                        format!(
2277                            "{}; its landing and the re-judgement after it do not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads",
2278                            not_now.why
2279                        ),
2280                    );
2281                    break;
2282                }
2283                *question_reads += rejudge;
2284                match self
2285                    .land_carried(
2286                        subject_named_beef,
2287                        txid,
2288                        &carried,
2289                        &topic,
2290                        mode,
2291                        bound,
2292                        question_reads,
2293                        landed,
2294                        landing,
2295                        landed_breaches,
2296                    )
2297                    .await
2298                {
2299                    Ok(()) => {
2300                        *v = self
2301                            .validate_topic(
2302                                tx,
2303                                txid,
2304                                &topic,
2305                                off_chain_values,
2306                                mode,
2307                                &TopicAdmittanceContext::default(),
2308                                bound,
2309                            )
2310                            .await;
2311                        steak.insert(topic.clone(), v.admittance.clone());
2312                        if !found_no_coin(v) {
2313                            break;
2314                        }
2315                    }
2316                    Err(did_not_land) => {
2317                        // No re-judgement follows: its reads were not made.
2318                        *question_reads -= rejudge;
2319                        waits.insert(
2320                            topic.clone(),
2321                            format!(
2322                                "{}; submitted first from the BEEF, it did not land ({did_not_land})",
2323                                not_now.why
2324                            ),
2325                        );
2326                        break;
2327                    }
2328                }
2329            }
2330            // A subject that already HOLDS an output in the topic landed
2331            // there before (the leftover of its own earlier submit whose
2332            // delete was started and faulted, D17 M1: its outputs held, no
2333            // applied row), and that admission was judged with its coin.
2334            // Its replay finishes it and does not wait (the E1D lens fold,
2335            // L4: a proven head spend that names a decoy no peer serves was
2336            // "not now" on every replay, three and a dead letter). One read,
2337            // only on the way to "not now", inside the allowance.
2338            if !finalize
2339                && waits.contains_key(&topic)
2340                && *question_reads < PREDECESSOR_READS_PER_SUBMIT
2341            {
2342                *question_reads += 1;
2343                if let Ok(outputs) = stored(
2344                    bound,
2345                    self.storage.find_outputs_for_transaction(txid, false),
2346                )
2347                .await
2348                {
2349                    if outputs.iter().any(|o| o.topic == topic) {
2350                        info!("topic {topic}: {txid} holds its outputs already; its replay finishes it");
2351                        waits.remove(&topic);
2352                        blocked.remove(&topic);
2353                    }
2354                }
2355            }
2356        }
2357        waits
2358    }
2359
2360    /// Land an unlanded predecessor whose body the successor's BEEF carries
2361    /// (lane E1D's lens fold, M1): submit it on its own to `topic`, its
2362    /// atomic BEEF out of the successor's, no off-chain values (the caller
2363    /// lands nothing into a topic that reads them; the delta fold, L1),
2364    /// never a broadcast (`current-tx` is submitted as `historical-tx`: the
2365    /// successor's BEEF carries it to the network).
2366    ///
2367    /// Each body is WALKED once in this submit (the delta fold, L4): the
2368    /// successor's own SPV walk (`subject`, the same mode, tracker and
2369    /// switch) already passed over every body it reaches without crossing a
2370    /// proven one, so those are not walked again; a body reached only
2371    /// through a proven one (which the successor's walk does not descend)
2372    /// gets its own walk, trusting what this submit already walked, and so
2373    /// does nothing twice. A walk that refuses a body ends the landing.
2374    ///
2375    /// A predecessor whose own submit waits on a carried predecessor of ITS
2376    /// own goes on a stack under it: ancestors first, the walk's rule at
2377    /// the door. Its manager was asked only a DRY RUN then and nothing was
2378    /// written; once the one under it landed it is submitted again and
2379    /// admitted for real, once ([`Engine::submit_counted`]). Each submit's
2380    /// reads on its clean path (its applied row, one coin per input, and
2381    /// its writes' one read of each coin it spends) are charged to the
2382    /// submit's allowance before it starts, and its predecessor
2383    /// question shares that allowance, so the landings of one submit stop
2384    /// at [`PREDECESSOR_READS_PER_SUBMIT`] reads, however deep the chain.
2385    /// Each body that lands is pushed on `landed` (the report's
2386    /// `landed_predecessors`, for the caller's admission guard; the delta
2387    /// fold, L2). Each body is asked of the caller's landing guard first,
2388    /// once ([`Engine::set_landing_guard`]; the delta-2 fold, L2): a refusal
2389    /// ends the landing before that body is submitted.
2390    /// `Ok` once `predecessor` LANDED in `topic` (recorded there,
2391    /// now or before); otherwise why not, at the first that did not.
2392    #[allow(clippy::too_many_arguments)]
2393    async fn land_carried(
2394        &self,
2395        beef_bytes: &[u8],
2396        subject: &str,
2397        predecessor: &str,
2398        topic: &str,
2399        mode: SubmitMode,
2400        bound: Option<&CallBound>,
2401        question_reads: &mut usize,
2402        landed: &mut Vec<(String, String)>,
2403        landing: Landing,
2404        landed_breaches: &mut Vec<WalkCouldNotRun>,
2405    ) -> Result<(), String> {
2406        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
2407            .map_err(|e| format!("the BEEF could not be read again ({e})"))?;
2408        let mode = match mode {
2409            SubmitMode::CurrentTx => SubmitMode::HistoricalTx,
2410            other => other,
2411        };
2412        let walks = mode != SubmitMode::HistoricalTxNoSpv;
2413        // The trust set is what a walk that RAN covered (the E592 lens fold,
2414        // M1): a subject whose walk breached covered nothing, so each carried
2415        // predecessor is walked by its own submit (and its own breach is
2416        // reported, `landed_breaches`), never trusted by a breached walk.
2417        let mut walked: HashSet<String> = if walks && landing.subject_walk_ran {
2418            walk_cover(&beef, subject)
2419        } else {
2420            HashSet::new()
2421        };
2422        let mut stack = vec![predecessor.to_string()];
2423        let mut guarded: HashSet<String> = HashSet::new();
2424        while let Some(next) = stack.last().cloned() {
2425            let body = beef
2426                .find_atomic_transaction(&next)
2427                .ok_or_else(|| format!("{next}: its body is not in the BEEF"))?;
2428            // The caller's admission predicate, before anything of this body
2429            // is submitted (the delta-2 fold, L2).
2430            if let Some(guard) = &self.landing_guard {
2431                if guarded.insert(next.clone()) {
2432                    bounded(bound, guard(&next, topic))
2433                        .await
2434                        .and_then(|answer| answer)
2435                        .map_err(|why| format!("{next}: refused by the landing guard ({why})"))?;
2436                }
2437            }
2438            // Its validation (the applied row, one coin per input) and its
2439            // writes' one read of each coin it spends.
2440            let cost = 1 + 2 * body.inputs.len();
2441            if *question_reads + cost > PREDECESSOR_READS_PER_SUBMIT {
2442                return Err(format!(
2443                    "{next}: its submit does not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2444                ));
2445            }
2446            *question_reads += cost;
2447            let atomic = body
2448                .to_atomic_beef(true)
2449                .map_err(|e| format!("{next}: its BEEF could not be built ({e})"))?;
2450            let tagged = TaggedBEEF::new(atomic, vec![topic.to_string()]);
2451            let (_, report, blocked) = match self
2452                .submit_counted(
2453                    &tagged,
2454                    mode,
2455                    bound,
2456                    false,
2457                    Some(&walked),
2458                    question_reads,
2459                    landing.policy,
2460                )
2461                .await
2462            {
2463                Ok(counted) => counted,
2464                // Its walk could not run and nothing stands in for it: it does
2465                // not land now, and the successor waits (counted, L3).
2466                Err(EngineError::WalkCouldNotRun(stop)) => {
2467                    let why = format!("{next}: {}", EngineError::WalkCouldNotRun(stop.clone()));
2468                    landed_breaches.push(stop);
2469                    return Err(why);
2470                }
2471                Err(e) => return Err(format!("{next}: {e}")),
2472            };
2473            // Its walk passed, so did that of every body it reaches; a walk
2474            // that went on past a breach (`NetworkAccepted`) passed nothing.
2475            if let Some(stop) = &report.walk_could_not_run {
2476                landed_breaches.push(stop.clone());
2477            } else if walks && !walked.contains(&next) {
2478                walked.extend(walk_cover(&beef, &next));
2479            }
2480            let landed_now = report.applied_topics.iter().any(|t| t == topic);
2481            if report.is_durable()
2482                && (landed_now || report.deduped_topics.iter().any(|t| t == topic))
2483            {
2484                info!("topic {topic}: {next} landed first from its successor's BEEF");
2485                if landed_now {
2486                    landed.push((next.clone(), topic.to_string()));
2487                }
2488                stack.pop();
2489                continue;
2490            }
2491            match blocked.get(topic) {
2492                Some(under) if !stack.contains(under) => stack.push(under.clone()),
2493                _ if report.is_durable() => return Err(format!("{next}: the manager failed it")),
2494                _ => return Err(format!("{next}: {}", report.summary())),
2495            }
2496        }
2497        Ok(())
2498    }
2499
2500    /// A topic of a submit that is NOT recorded as applied: logged, and
2501    /// remembered for the successor rule (`not_landed`).
2502    fn hold_unapplied(&self, txid: &str, topic: &str, why: &str) {
2503        warn!("topic {topic}: {txid}: {why}; applied_transactions NOT recorded (bsv-low #559)");
2504        self.not_landed
2505            .borrow_mut()
2506            .insert((txid.to_string(), topic.to_string()));
2507    }
2508
2509    /// Take out again the outputs a faulted submit inserted (bsv-low #559,
2510    /// the lens fold of 2026-10-07, F1), so that nothing of a transaction
2511    /// that did not land is held. Only a row nobody has touched since is
2512    /// deleted: unspent and consumed by nothing. A plain delete, not the
2513    /// deep one: the row's retained inputs were not pointed at it yet. A
2514    /// fault here is reported (`undo_insert_output`) and the row stays: the
2515    /// limit of an undo made of separate calls.
2516    async fn undo_inserts(
2517        &self,
2518        txid: &str,
2519        topic: &str,
2520        output_indices: &[u32],
2521        bound: Option<&CallBound>,
2522        report: &mut MutationReport,
2523    ) {
2524        for &output_index in output_indices {
2525            let row = stored(
2526                bound,
2527                self.storage
2528                    .find_output(txid, output_index, Some(topic), None, false),
2529            )
2530            .await;
2531            let undone = match row {
2532                Ok(Some(row)) if !row.spent && row.consumed_by.is_empty() => {
2533                    stored(bound, self.storage.delete_output(txid, output_index, topic)).await
2534                }
2535                Ok(_) => Ok(()),
2536                Err(e) => Err(e),
2537            };
2538            if let Err(e) = undone {
2539                error!("Error taking {txid}.{output_index} out of topic {topic} again: {e}");
2540                report.fault(topic, "undo_insert_output", e.to_string());
2541            }
2542        }
2543    }
2544
2545    /// The store's own answer to "has a predecessor of `tx` not landed?"
2546    /// (bsv-low #559, the lens fold of 2026-10-07, F3; lane E1D, bsv-low
2547    /// #575, and its lens fold of 2026-10-08), for a transaction that found
2548    /// no previous coin. The engine's memory of a faulted submit
2549    /// (`not_landed`) dies with the invocation; this does not. A transaction
2550    /// HAS LANDED in `topic` when it has an applied row there or an output
2551    /// held there; anything else is not known to have landed, and it is a
2552    /// PREDECESSOR of `tx` when one of these says so:
2553    ///
2554    /// - its body is in the BEEF and it spends a coin the topic HOLDS (or,
2555    ///   one step up, a coin of another such transaction): a spend of this
2556    ///   topic the store has not taken yet (#559);
2557    /// - its body is in the BEEF, it spends no coin the topic holds, the
2558    ///   manager NAMES the outpoint the walk spends from it as overlay
2559    ///   history (`identify_needed_inputs` over the BEEF of the transaction
2560    ///   that spends it, `tx` or a body the walk reached), and the manager,
2561    ///   asked of that body in a dry run with no coins
2562    ///   (`TopicAdmittanceContext::DRY_RUN`, the submit's mode, no off-chain
2563    ///   values), would admit that named output: an OPENER that has not
2564    ///   landed (E1D, the cure D17 named). Only a NAMED output is dry-run
2565    ///   (the lens fold, H1): with every unheld body dry-run, a manager that
2566    ///   admits on output shape (all 16 of this workspace) made a spend of a
2567    ///   shape-admissible output this node never held (a revocation of an
2568    ///   ad it never saw; a stranger's own few-sat SHIP output) "not now" on
2569    ///   every presentation, three replays and a dead letter each, where the
2570    ///   reference records it. A manager `Err` in the dry run is "not now"
2571    ///   (the manager's contract at the anchor replay, D15), except its
2572    ///   typed refusal `NoAdmissibleOutputs`, which is "not admitted";
2573    /// - its body is NOT in the BEEF (a PROVEN successor carries none) and
2574    ///   the manager names the outpoint `tx` spends from it as overlay
2575    ///   history (D13's word; an `Err` there names nothing, as in the walk).
2576    ///
2577    /// The manager is asked over `beef_bytes`, the SUBJECT-NAMED BEEF of the
2578    /// submit (BRC-95 atomic, the body the lookup services get; the lens
2579    /// fold, M2): over the submitted bytes a manager that parses
2580    /// `from_beef(_, None)` took the wire-LAST transaction of a non-atomic
2581    /// BEEF, named an ancestor's inputs, and the question asked nothing.
2582    ///
2583    /// `admits` says which inputs of `tx` start the walk. A transaction that
2584    /// admits nothing (D17's class) starts from every input. One that admits
2585    /// WITHOUT its coin (E1D) starts only from the inputs the manager names:
2586    /// a manager that admits a successor with no coin has said the coin does
2587    /// not decide the admission, so only its own word makes an input a
2588    /// predecessor. Every manager of this workspace names nothing: an
2589    /// admitting successor is then asked nothing, as in the reference, and
2590    /// so is a spend of an unheld output nobody names (the lens fold, H1);
2591    /// but a successor that admits nothing still finds case (a), a carried
2592    /// body that spends a coin the topic holds, and waits for it or has it
2593    /// landed first, where the reference records the successor and never
2594    /// admits that body (the E1D delta fold, L3). A named input whose body
2595    /// the BEEF carries is still walked and dry-run, so a named decoy the
2596    /// manager would not admit is no predecessor.
2597    ///
2598    /// The answer carries the predecessor when its body is in the BEEF (the
2599    /// first two cases): the door lands it first ([`Engine::successors_waiting`]).
2600    ///
2601    /// The limits, stated. It cannot tell a faulted predecessor from one
2602    /// nobody submitted yet: a successor is "not now" in both until the
2603    /// predecessor lands (the door lands a carried one itself). A named
2604    /// input whose body is absent and which never lands (a decoy no one
2605    /// submits, beside a real predecessor that landed and holds no coin)
2606    /// keeps its successor "not now" at the door; the GASP walk prunes such
2607    /// an input instead (D14). Deeper than the successor's own inputs, an
2608    /// absent body ends the walk as before.
2609    ///
2610    /// "Landed" needs a clean answer (the delta fold of 2026-10-07, M1 and
2611    /// M2): a read that faults, or the question running out of its reads
2612    /// (the 16 of a topic or the 256 of the submit), answers "not now" too,
2613    /// naming the transaction it could not settle. Recording the successor
2614    /// there made its replay a dupe and stopped the chain behind it for
2615    /// good; a retried no-op is the lesser cost.
2616    ///
2617    /// What the bound counts (the second delta fold of 2026-10-07, M2): the
2618    /// reads spent on a body that is NEITHER proven in the BEEF NOR found
2619    /// landed, [`PREDECESSOR_READS`] of them per topic. A body the BEEF
2620    /// proves, and one whose applied row or held output answers "landed", is
2621    /// read and costs nothing against that bound: counted, they made a
2622    /// transaction that admits nothing over 17 landed parents, over six
2623    /// proven parents the topic never saw, or over one proven parent with 15
2624    /// inputs "not now" on every submit, with no unlanded predecessor
2625    /// anywhere. A landed body still needs ROOM under it: each read of an
2626    /// unproven body is tested before it is made, so with 15 counted reads
2627    /// spent one that landed by a held output (no applied row) is refused at
2628    /// its second read, and with 16 spent any unproven body at its first.
2629    ///
2630    /// "Proven" is the BEEF's word here: a body that carries a merkle path
2631    /// (a bump index) is counted as proven for the read bound ONLY. No proof
2632    /// is checked at this question; the SPV walk of the submit and the GASP
2633    /// anchor check are where proofs are checked, and under
2634    /// `historical-tx-no-spv` or the scripts-only walk nothing checked this
2635    /// one, so the 16 are the caller's to switch off. Nothing is recorded on
2636    /// that word (a proven body is walked like any other), and the allowance
2637    /// below does not ask it.
2638    ///
2639    /// The hard allowance (the third delta fold of 2026-10-07, M1):
2640    /// `question_reads`, owned by the submit, counts EVERY read of the
2641    /// question, those of proven and landed bodies included, over every
2642    /// topic of the submit, and stops it at
2643    /// [`PREDECESSOR_READS_PER_SUBMIT`]; the 16 stay inside it. A later
2644    /// topic's question starts from what the earlier ones left.
2645    ///
2646    /// The costs, stated. A transaction that admits nothing, found no coin
2647    /// and carries more UNPROVEN, UNLANDED bodies than 16 reads settle (five
2648    /// single-input ancestors, fewer with more inputs), or a BEEF whose
2649    /// question needs more than 256 reads over the submit's topics (a
2650    /// landed body costs one read by its applied row or two by a held
2651    /// output, a proven, unlanded one two and one per input), is never
2652    /// recorded, where the reference records it: each submit of it is a
2653    /// fault and a replay. The manager CPU it adds: one names call per
2654    /// spender a dry run needs (the subject's, and one per walked body with
2655    /// an unheld source), and at most one dry run per body the reads
2656    /// reached; each candidate is built out of the BEEF parsed once (the
2657    /// lens fold, L2).
2658    ///
2659    /// Returns why the submit must wait, for the report.
2660    #[allow(clippy::too_many_arguments)]
2661    async fn unlanded_predecessor(
2662        &self,
2663        tx: &Transaction,
2664        txid: &str,
2665        beef_bytes: &[u8],
2666        topic: &str,
2667        mode: SubmitMode,
2668        admits: bool,
2669        subject_names: &mut Option<HashSet<String>>,
2670        question_reads: &mut usize,
2671        bound: Option<&CallBound>,
2672    ) -> Option<NotNow> {
2673        let manager = self.managers.get(topic)?;
2674        let wait = |why: String| Some(NotNow { why, carried: None });
2675        // The outpoints `tx` spends, as `txid.vout`.
2676        let starts: Vec<(String, u32)> = tx
2677            .inputs
2678            .iter()
2679            .filter_map(|input| {
2680                let source = input.get_source_txid().ok()?;
2681                (!source.is_empty()).then_some((source, input.source_output_index))
2682            })
2683            .collect();
2684        // The outputs of each candidate that the walk spends, and who spends
2685        // each: a dry run asks only of an output its spender names.
2686        let mut spent_of: HashMap<String, HashSet<u32>> = HashMap::new();
2687        let mut spenders: HashMap<String, Vec<(String, u32)>> = HashMap::new();
2688        let mut ask: Vec<String> = Vec::new();
2689        for (source, vout) in &starts {
2690            if admits
2691                && subject_names
2692                    .as_ref()
2693                    .is_some_and(|named| !named.contains(&format!("{source}.{vout}")))
2694            {
2695                continue;
2696            }
2697            spent_of.entry(source.clone()).or_default().insert(*vout);
2698            spenders
2699                .entry(source.clone())
2700                .or_default()
2701                .push((txid.to_string(), *vout));
2702            ask.push(source.clone());
2703        }
2704        // Nothing to ask (a transaction that admits and names nothing, every
2705        // manager of this workspace): the BEEF is not even parsed.
2706        if ask.is_empty() {
2707            return None;
2708        }
2709        let direct: HashSet<String> = ask.iter().cloned().collect();
2710        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS).ok()?;
2711        // Each body the BEEF carries, and whether the BEEF says it is
2712        // proven (a bump index: its word, no proof is checked here).
2713        let bodies: HashMap<String, (&Transaction, bool)> = beef
2714            .txs
2715            .iter()
2716            .filter_map(|btx| {
2717                btx.tx()
2718                    .map(|body| (btx.txid(), (body, btx.bump_index().is_some())))
2719            })
2720            .collect();
2721        // The manager's names over each walked body other than `tx`.
2722        let mut names_over: HashMap<String, HashSet<String>> = HashMap::new();
2723        // The reads spent on bodies neither proven nor found landed.
2724        let mut reads = 0usize;
2725        let mut asked: HashSet<String> = HashSet::new();
2726        while let Some(candidate) = ask.pop() {
2727            if candidate.is_empty() || !asked.insert(candidate.clone()) {
2728                continue;
2729            }
2730            let spent_outputs = spent_of.get(&candidate).cloned().unwrap_or_default();
2731            // An absent body is asked of the store only when it is one of
2732            // `tx`'s own inputs and the manager names it (E1D).
2733            let body = bodies.get(&candidate).copied();
2734            if body.is_none() {
2735                if !direct.contains(&candidate) {
2736                    continue;
2737                }
2738                if subject_names.is_none() {
2739                    *subject_names = Some(
2740                        self.named_history(manager.as_ref(), beef_bytes, bound)
2741                            .await,
2742                    );
2743                }
2744                let is_named = subject_names.as_ref().is_some_and(|named| {
2745                    spent_outputs
2746                        .iter()
2747                        .any(|vout| named.contains(&format!("{candidate}.{vout}")))
2748                });
2749                if !is_named {
2750                    continue;
2751                }
2752            }
2753            let proven = body.is_some_and(|(_, proven)| proven);
2754            // This candidate's own reads: they join `reads` once it is
2755            // found not landed, and never if the BEEF proves it.
2756            let mut spent = 0usize;
2757            let out_of = |spent: usize| !proven && reads + spent >= PREDECESSOR_READS;
2758            let out_of_reads = || {
2759                wait(format!(
2760                    "{candidate} is not known to have landed: the question ran out of its {PREDECESSOR_READS} reads"
2761                ))
2762            };
2763            // The submit's allowance, over every read: proven or landed,
2764            // this topic or an earlier one.
2765            let out_of_allowance = || {
2766                wait(format!(
2767                    "{candidate} is not known to have landed: the question ran out of the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2768                ))
2769            };
2770            let unread = |e: StorageError| {
2771                wait(format!(
2772                    "{candidate} is not known to have landed: the store could not say ({e})"
2773                ))
2774            };
2775            let carried = |why: String| {
2776                Some(NotNow {
2777                    why,
2778                    carried: Some(candidate.clone()),
2779                })
2780            };
2781            let record = AppliedTransaction {
2782                txid: candidate.clone(),
2783                topic: topic.to_string(),
2784            };
2785            // An applied row, or an output held, ends the question for this
2786            // transaction: it landed.
2787            if out_of(spent) {
2788                return out_of_reads();
2789            }
2790            if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2791                return out_of_allowance();
2792            }
2793            spent += 1;
2794            *question_reads += 1;
2795            match stored(bound, self.storage.does_applied_transaction_exist(&record)).await {
2796                Ok(false) => {}
2797                Ok(true) => continue,
2798                Err(e) => return unread(e),
2799            }
2800            if out_of(spent) {
2801                return out_of_reads();
2802            }
2803            if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2804                return out_of_allowance();
2805            }
2806            spent += 1;
2807            *question_reads += 1;
2808            match stored(
2809                bound,
2810                self.storage.find_outputs_for_transaction(&candidate, false),
2811            )
2812            .await
2813            {
2814                Ok(outputs) if outputs.iter().all(|o| o.topic != topic) => {}
2815                Ok(_) => continue,
2816                Err(e) => return unread(e),
2817            }
2818            // Not landed. With no body to walk, the manager's word is the
2819            // whole answer (E1D).
2820            let Some((body, _)) = body else {
2821                return wait(format!(
2822                    "{candidate} has not landed (the manager names it as history; the BEEF does not carry it)"
2823                ));
2824            };
2825            for input in &body.inputs {
2826                let source = input.get_source_txid().unwrap_or_default();
2827                if source.is_empty() {
2828                    continue;
2829                }
2830                if out_of(spent) {
2831                    return out_of_reads();
2832                }
2833                if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2834                    return out_of_allowance();
2835                }
2836                spent += 1;
2837                *question_reads += 1;
2838                let coin = stored(
2839                    bound,
2840                    self.storage.find_output(
2841                        &source,
2842                        input.source_output_index,
2843                        Some(topic),
2844                        None,
2845                        false,
2846                    ),
2847                )
2848                .await;
2849                match coin {
2850                    Ok(Some(_)) => return carried(format!("{candidate} has not landed")),
2851                    // Not held: its own transaction may be the one that has
2852                    // not landed.
2853                    Ok(None) => {
2854                        spent_of
2855                            .entry(source.clone())
2856                            .or_default()
2857                            .insert(input.source_output_index);
2858                        spenders
2859                            .entry(source.clone())
2860                            .or_default()
2861                            .push((candidate.clone(), input.source_output_index));
2862                        ask.push(source);
2863                    }
2864                    Err(e) => return unread(e),
2865                }
2866            }
2867            // It spends no coin the topic holds. Does a spender NAME an
2868            // output the walk spends of it (the lens fold, H1)? A names call
2869            // per spender, no store read.
2870            let mut named_outputs: HashSet<u32> = HashSet::new();
2871            for (spender, vout) in spenders.get(&candidate).cloned().unwrap_or_default() {
2872                let names = if spender == txid {
2873                    if subject_names.is_none() {
2874                        *subject_names = Some(
2875                            self.named_history(manager.as_ref(), beef_bytes, bound)
2876                                .await,
2877                        );
2878                    }
2879                    subject_names.as_ref()
2880                } else {
2881                    if !names_over.contains_key(&spender) {
2882                        let names = match beef
2883                            .find_atomic_transaction(&spender)
2884                            .and_then(|spender| spender.to_atomic_beef(true).ok())
2885                        {
2886                            Some(spender_beef) => {
2887                                self.named_history(manager.as_ref(), &spender_beef, bound)
2888                                    .await
2889                            }
2890                            None => HashSet::new(),
2891                        };
2892                        names_over.insert(spender.clone(), names);
2893                    }
2894                    names_over.get(&spender)
2895                };
2896                if names.is_some_and(|named| named.contains(&format!("{candidate}.{vout}"))) {
2897                    named_outputs.insert(vout);
2898                }
2899            }
2900            if !named_outputs.is_empty() {
2901                // Would the manager admit a named output of it with no coins
2902                // (E1D, the opener)? A dry run over the candidate built out
2903                // of the BEEF parsed above (its proof and, unproven, its
2904                // sources linked): no store read, nothing counted.
2905                let judged = beef
2906                    .find_atomic_transaction(&candidate)
2907                    .unwrap_or_else(|| body.clone());
2908                let verdict = bounded(
2909                    bound,
2910                    manager.identify_admissible_outputs(
2911                        &judged,
2912                        &[],
2913                        None,
2914                        mode,
2915                        &TopicAdmittanceContext::DRY_RUN,
2916                    ),
2917                )
2918                .await;
2919                match verdict {
2920                    Ok(Ok(admittance)) => {
2921                        if admittance
2922                            .outputs_to_admit
2923                            .iter()
2924                            .any(|vout| named_outputs.contains(vout))
2925                        {
2926                            return carried(format!(
2927                                "{candidate} has not landed (the manager names the output spent from it and would admit it with no coin: an opener)"
2928                            ));
2929                        }
2930                    }
2931                    Ok(Err(TopicManagerError::NoAdmissibleOutputs(_))) => {}
2932                    Ok(Err(e)) => {
2933                        return wait(format!(
2934                            "{candidate} is not known to have landed: the manager could not judge it ({e})"
2935                        ))
2936                    }
2937                    Err(no_answer) => {
2938                        return wait(format!(
2939                            "{candidate} is not known to have landed: the manager did not answer ({no_answer})"
2940                        ))
2941                    }
2942                }
2943            }
2944            if !proven {
2945                reads += spent;
2946            }
2947        }
2948        None
2949    }
2950
2951    /// The outpoints (`txid.vout`) the topic manager NAMES as overlay
2952    /// history of the transaction `beef_bytes` names (`identify_needed_inputs`,
2953    /// D13's word; an atomic BEEF, so a manager that parses
2954    /// `from_beef(_, None)` reads that transaction). An `Err` or no answer
2955    /// names nothing, as the GASP walk treats it (logged).
2956    async fn named_history(
2957        &self,
2958        manager: &dyn TopicManager,
2959        beef_bytes: &[u8],
2960        bound: Option<&CallBound>,
2961    ) -> HashSet<String> {
2962        match bounded(bound, manager.identify_needed_inputs(beef_bytes, None)).await {
2963            Ok(Ok(named)) => named.iter().map(Outpoint::to_graph_id).collect(),
2964            Ok(Err(e)) => {
2965                warn!("the predecessor question: the manager could not name its inputs: {e}");
2966                HashSet::new()
2967            }
2968            Err(no_answer) => {
2969                warn!("the predecessor question: the manager did not name its inputs: {no_answer}");
2970                HashSet::new()
2971            }
2972        }
2973    }
2974
2975    /// The reference's `tx.verify(chainTracker)` on submit: every unproven
2976    /// transaction from the subject down has every input's script executed
2977    /// against its source output and obeys the value rule; a proven one is
2978    /// not descended, and its BUMP's root is checked against the chain
2979    /// tracker (computed and accepted with none).
2980    ///
2981    /// Since bsv-low #592 (the owner's ruling 3a of 2026-10-10) the scripts
2982    /// are walked by the DOOR's stream walk ([`crate::script_door`]) under
2983    /// `budget`, the engine's [`DoorBudget`]: one walker, the same estimates
2984    /// charged from the bytes before anything is parsed or run. Before it this
2985    /// was a walk of its own over a hydrated `Beef` with no memory charge and
2986    /// no interpreter limit: 219.7 MB natively on a 1.9 MB push-only unproven
2987    /// parent, an isolate kill on Workers, met by every `historical-tx` replay
2988    /// of the queue. The roots are then asked for the proven transactions the
2989    /// walk reached, in its order, once per BUMP.
2990    ///
2991    /// `Ok(Some(_))` is a walk that COULD NOT RUN within the budget, never a
2992    /// refusal (below, at the match). The interpreter's refusal and a
2993    /// structural fault are what they were: `ScriptVerificationFailed`, and
2994    /// `SpvError` in the same words.
2995    ///
2996    /// The tracker is an ARGUMENT, not `self`'s: the GASP anchor check
2997    /// ([`verify_spv_like_the_reference`]) runs this same walk from
2998    /// `OverlayGASPStorage`, which borrows the engine's tracker.
2999    ///
3000    /// Why not bsv-rs `Transaction::verify`: it walks `source_transaction`
3001    /// links that `from_beef` builds by CLONING, once per input. Two inputs
3002    /// sourcing the same unproven parent (a covenant head output plus that
3003    /// spend's own change, the shape of every second head spend of a wallet)
3004    /// leave the second clone bare ("Input 0 has no source transaction"), and
3005    /// re-linking every clone materializes the ancestry EXPONENTIALLY along a
3006    /// diamond chain: a 12-deep unmined chain of head spends took a Worker past
3007    /// its memory on beta (zanaadu, 2026-09-08). The stream walk reads each
3008    /// transaction once, in place, keyed by txid.
3009    async fn verify_beef_linear(
3010        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3011        beef_bytes: &[u8],
3012        subject_txid: &str,
3013        trusted: &HashSet<String>,
3014        budget: DoorBudget,
3015    ) -> Result<Option<WalkCouldNotRun>, EngineError> {
3016        // A structural fault is the reference walk's `SpvError`.
3017        let spv =
3018            |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3019
3020        // THE SCRIPTS (bsv-low #592): the door's own stream walk, under the
3021        // engine's budget. One walker (Rule 10): the door and the submit run
3022        // the same function, the same estimates and the same interpreter
3023        // limit; the submit adds the roots below.
3024        let mut trace = crate::script_door::WalkTrace::default();
3025        match crate::script_door::walk_trusting(
3026            beef_bytes,
3027            subject_txid,
3028            budget,
3029            trusted,
3030            &mut trace,
3031        ) {
3032            Ok(_) => {}
3033            // The interpreter's verdict, the reference's refusal: unchanged.
3034            Err(refused @ EngineError::ScriptVerificationFailed { .. }) => return Err(refused),
3035            // A structural fault (a source missing, the value rule, ...):
3036            // the reference walk's `SpvError`, in the same words as before.
3037            Err(EngineError::ScriptWalkInconclusive { reason, .. }) => return Err(spv(reason)),
3038            // The budget, not the transaction: the walk COULD NOT RUN. Not a
3039            // refusal: the bytes are a BEEF the walk was not given the room
3040            // to judge, which says nothing about whether its scripts are
3041            // valid (refusing would refuse a valid BEEF for its size, the
3042            // owner's posture). Not an admission either: the caller's
3043            // [`WalkBreachPolicy`] decides, and only the network's word on
3044            // record lets the submit go on (the E592 lens fold, H1). The roots
3045            // the walk reached are checked first, below.
3046            Err(EngineError::ScriptWalkOverBudget {
3047                at_txid,
3048                subject_judged,
3049                limb,
3050                what,
3051            }) => {
3052                let limb = match limb {
3053                    DoorLimb::Memory => WalkLimb::OverMemory,
3054                    DoorLimb::Work if trace.interpreter_tripped => WalkLimb::InterpreterMemory,
3055                    DoorLimb::Work => WalkLimb::OverWork,
3056                };
3057                // THE ROOTS OF A BREACHED WALK (the E592 lens fold, H1): every
3058                // proven transaction it reached, and every proven source an
3059                // input read, whatever the scripts' verdict. A bad root is
3060                // the `SpvError` it is on a walk that ran: a breach never
3061                // skips a proof the walk relied on. Their cost is a tracker
3062                // question per BUMP, which the budget does not bound and
3063                // does not need to (the clean walk asks the same).
3064                let reached: Vec<String> = trace
3065                    .proven
3066                    .iter()
3067                    .chain(&trace.proven_sources)
3068                    .cloned()
3069                    .collect();
3070                Self::check_reached_roots(chain_tracker, beef_bytes, &reached).await?;
3071                return Ok(Some(WalkCouldNotRun {
3072                    subject_txid: subject_txid.to_string(),
3073                    at_txid,
3074                    subject_judged,
3075                    limb,
3076                    what,
3077                }));
3078            }
3079            Err(other) => return Err(other),
3080        }
3081
3082        // THE ROOTS: every proven transaction the walk reached, in the order
3083        // it reached them, its BUMP's root computed and asked of the tracker
3084        // once per BUMP (NL-6e). With no tracker the roots are computed and
3085        // accepted (`'scripts only'`). The walk trusted them as it went; the
3086        // reference checks them in the same walk, so a body with BOTH a bad
3087        // script and a bad root now names the script.
3088        Self::check_reached_roots(chain_tracker, beef_bytes, &trace.proven).await?;
3089        Ok(None)
3090    }
3091
3092    /// The roots of `reached` (display txids, in order; a repeat or a second
3093    /// transaction of one BUMP asks nothing more), each BUMP's root computed
3094    /// and asked of the tracker once. None reached: the body is not parsed.
3095    async fn check_reached_roots(
3096        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3097        beef_bytes: &[u8],
3098        reached: &[String],
3099    ) -> Result<(), EngineError> {
3100        let spv =
3101            |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3102        if reached.is_empty() {
3103            return Ok(());
3104        }
3105        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
3106            .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3107        let proven_in = proven_index(&beef);
3108        let mut checked: HashSet<usize> = HashSet::new();
3109        for txid in reached {
3110            let Some(&at) = proven_in.get(txid.as_str()) else {
3111                return Err(spv(format!("transaction {txid} is not in the BEEF")));
3112            };
3113            if !checked.insert(at) {
3114                continue; // its BUMP's root is the chain's, asked once
3115            }
3116            let mp = &beef.bumps[at];
3117            let root = mp
3118                .compute_root(Some(txid))
3119                .map_err(|e| spv(format!("invalid merkle path for transaction {txid}: {e}")))?;
3120            if let Some(tracker) = chain_tracker {
3121                match tracker
3122                    .is_valid_root_for_height(&root, mp.block_height)
3123                    .await
3124                {
3125                    Ok(true) => {}
3126                    Ok(false) => {
3127                        return Err(EngineError::SpvError(format!(
3128                            "Invalid merkle path for transaction {txid}: root {root} is not valid for block height {}",
3129                            mp.block_height
3130                        )));
3131                    }
3132                    Err(e) => {
3133                        return Err(EngineError::SpvError(format!(
3134                            "Chain tracker error at height {}: {e}",
3135                            mp.block_height
3136                        )));
3137                    }
3138                }
3139            }
3140        }
3141        Ok(())
3142    }
3143
3144    /// The pre-2026-09-08 SPV block, kept as the
3145    /// [`Engine::set_script_verification`] `false` escape hatch: BEEF
3146    /// structural validity plus every root against the chain tracker, and
3147    /// NOTHING when no tracker is configured. It never executes a script.
3148    ///
3149    /// The structure is the streaming reader's
3150    /// ([`beef_limits::structure_roots`], bsv-rs 0.4.2): one element in hand,
3151    /// each BUMP walked once, linear in its leaves (NL-6e). Until NL-6e it
3152    /// was `Beef::verify_valid`, whose root check at bsv-rs 0.4.0 walked a
3153    /// BUMP once per leaf: one BUMP of 8,192 leaves gave no answer in 120 s
3154    /// (bsv-stack-lean NL-8 W1). The reader reads the wire's order, where
3155    /// `verify_valid` sorted first: an input that names a later transaction
3156    /// is refused here.
3157    async fn verify_spv_structurally(
3158        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3159        beef_bytes: &[u8],
3160    ) -> Result<(), EngineError> {
3161        let Some(chain_tracker) = chain_tracker else {
3162            return Ok(());
3163        };
3164        let roots = beef_limits::structure_roots(beef_bytes)
3165            .map_err(|r| EngineError::SpvError(format!("BEEF structure refused: {r}")))?;
3166        let mut asked: HashSet<(u64, &str)> = HashSet::new();
3167        for (height, root) in &roots {
3168            if !asked.insert((*height, root.as_str())) {
3169                continue;
3170            }
3171            let Ok(height) = u32::try_from(*height) else {
3172                return Err(EngineError::SpvError(format!(
3173                    "Merkle root {root} invalid for block height {height}"
3174                )));
3175            };
3176            match chain_tracker.is_valid_root_for_height(root, height).await {
3177                Ok(true) => {}
3178                Ok(false) => {
3179                    return Err(EngineError::SpvError(format!(
3180                        "Merkle root {root} invalid for block height {height}"
3181                    )));
3182                }
3183                Err(e) => {
3184                    return Err(EngineError::SpvError(format!(
3185                        "Chain tracker error at height {height}: {e}"
3186                    )));
3187                }
3188            }
3189        }
3190        Ok(())
3191    }
3192
3193    /// Phase 1 of ONE topic: the dedup read, the previous coins, the
3194    /// manager's judgement. Read-only. [`Engine::run_validation`] asks it of
3195    /// every topic; the door asks it again of a topic whose predecessor it
3196    /// landed first from the BEEF (lane E1D's lens fold, M1).
3197    #[allow(clippy::too_many_arguments)]
3198    async fn validate_topic(
3199        &self,
3200        tx: &Transaction,
3201        txid: &str,
3202        topic: &str,
3203        off_chain_values: Option<&[u8]>,
3204        mode: SubmitMode,
3205        context: &TopicAdmittanceContext,
3206        bound: Option<&CallBound>,
3207    ) -> TopicValidation {
3208        let tx_record = AppliedTransaction {
3209            txid: txid.to_string(),
3210            topic: topic.to_string(),
3211        };
3212        // A dedup read that FAULTS is the topic's read fault (the delta
3213        // fold of 2026-10-07, H3): nothing is judged and nothing is
3214        // written for the topic, the fault rides the report and the
3215        // replay asks again. The reference fails the topic there
3216        // (`Engine.ts` `submit`: the throw lands in the per-topic catch,
3217        // `failedTopics.add`). Read as "not a dupe", as it was, a
3218        // re-presented transaction the manager admits with no previous
3219        // coin was inserted a second time, unspent, beside the output
3220        // that had spent it, under a durable report.
3221        let is_dupe = match stored(
3222            bound,
3223            self.storage.does_applied_transaction_exist(&tx_record),
3224        )
3225        .await
3226        {
3227            Ok(is_dupe) => is_dupe,
3228            Err(e) => {
3229                error!("Error reading the applied row of {txid} for topic {topic}: {e}");
3230                return TopicValidation {
3231                    topic: topic.to_string(),
3232                    is_dupe: false,
3233                    previous_coins: vec![],
3234                    previous_outputs: vec![],
3235                    admittance: AdmittanceInstructions::default(),
3236                    failed: false,
3237                    read_fault: Some(("does_applied_transaction_exist", e.to_string())),
3238                };
3239            }
3240        };
3241
3242        if is_dupe {
3243            return TopicValidation {
3244                topic: topic.to_string(),
3245                is_dupe: true,
3246                previous_coins: vec![],
3247                previous_outputs: vec![],
3248                admittance: AdmittanceInstructions::default(),
3249                failed: false,
3250                read_fault: None,
3251            };
3252        }
3253
3254        let mut previous_coins: Vec<u32> = Vec::new();
3255        let mut previous_outputs: Vec<Output> = Vec::new();
3256        let mut read_fault: Option<(&'static str, String)> = None;
3257
3258        for (input_idx, input) in tx.inputs.iter().enumerate() {
3259            let source_txid = input.get_source_txid().unwrap_or_default();
3260            if source_txid.is_empty() {
3261                continue;
3262            }
3263            match stored(
3264                bound,
3265                self.storage.find_output(
3266                    &source_txid,
3267                    input.source_output_index,
3268                    Some(topic),
3269                    None,
3270                    false,
3271                ),
3272            )
3273            .await
3274            {
3275                Ok(Some(prev_output)) => {
3276                    previous_coins.push(input_idx as u32);
3277                    previous_outputs.push(prev_output);
3278                }
3279                Ok(None) => {}
3280                Err(e) => {
3281                    // A faulted read is NOT "no previous coin": the
3282                    // spend may consume an admitted output we simply
3283                    // could not read. Validation proceeds (the topic
3284                    // manager decides on what it can see) but the fault
3285                    // rides the report and blocks the applied record.
3286                    error!(
3287                        "Error reading previous output {source_txid}:{} for topic {topic}: {e}",
3288                        input.source_output_index
3289                    );
3290                    if read_fault.is_none() {
3291                        read_fault = Some(("find_output", e.to_string()));
3292                    }
3293                }
3294            }
3295        }
3296
3297        let judged = self
3298            .judge(
3299                tx,
3300                topic,
3301                &previous_coins,
3302                off_chain_values,
3303                mode,
3304                context,
3305                bound,
3306            )
3307            .await;
3308        match judged {
3309            Ok(admittance) => TopicValidation {
3310                topic: topic.to_string(),
3311                is_dupe: false,
3312                previous_coins,
3313                previous_outputs,
3314                admittance,
3315                failed: false,
3316                read_fault: read_fault.clone(),
3317            },
3318            Err(e) => {
3319                error!("Error validating topic {topic} during submit: {e}");
3320                TopicValidation {
3321                    topic: topic.to_string(),
3322                    is_dupe: false,
3323                    previous_coins: vec![],
3324                    previous_outputs: vec![],
3325                    admittance: AdmittanceInstructions::default(),
3326                    failed: true,
3327                    read_fault,
3328                }
3329            }
3330        }
3331    }
3332
3333    /// The topic manager's judgement of `tx` over the previous coins found
3334    /// (input indices), as [`Engine::validate_topic`] asks it; a carried
3335    /// predecessor's submit asks it again for real once its topic does not
3336    /// wait (the E1D delta fold, L4).
3337    #[allow(clippy::too_many_arguments)]
3338    async fn judge(
3339        &self,
3340        tx: &Transaction,
3341        topic: &str,
3342        previous_coins: &[u32],
3343        off_chain_values: Option<&[u8]>,
3344        mode: SubmitMode,
3345        context: &TopicAdmittanceContext,
3346        bound: Option<&CallBound>,
3347    ) -> Result<AdmittanceInstructions, TopicManagerError> {
3348        let manager = &self.managers[topic];
3349        let previous_coin_bytes = previous_coins
3350            .iter()
3351            .flat_map(|i| i.to_le_bytes())
3352            .collect::<Vec<u8>>();
3353        // Under a finalize submit's bound a manager that does not answer
3354        // is a manager that failed: nothing is written for the topic.
3355        bounded(
3356            bound,
3357            manager.identify_admissible_outputs(
3358                // The ONE engine parse of the submitted BEEF, shared by
3359                // every topic on this submit (bsv-low #289 — managers
3360                // used to re-parse the same bytes independently).
3361                tx,
3362                &previous_coin_bytes,
3363                off_chain_values,
3364                mode,
3365                // `dry_run` is `false` on a submit, `true` on a
3366                // validate-only call.
3367                context,
3368            ),
3369        )
3370        .await
3371        .map_err(TopicManagerError::Other)
3372        .and_then(|judged| judged)
3373    }
3374
3375    /// Whether the door may land a carried predecessor into `topic` (the
3376    /// E1D delta fold, L1): neither its manager nor any lookup service
3377    /// reads off-chain values there, which the landing does not have.
3378    fn lands_without_values(&self, topic: &str) -> bool {
3379        self.managers
3380            .get(topic)
3381            .is_some_and(|manager| !manager.reads_off_chain_values())
3382            && self
3383                .lookup_services
3384                .values()
3385                .all(|ls| !ls.reads_off_chain_values(topic))
3386    }
3387
3388    /// Run Phase 1 (topic validation) and Phase 2 (broadcast) without mutating storage.
3389    ///
3390    /// Returns (validations, steak, parsed_tx, txid) so the caller can either
3391    /// stop (validate-only) or proceed with Phase 3 (mutations).
3392    async fn run_validation(
3393        &self,
3394        tagged_beef: &TaggedBEEF,
3395        mode: SubmitMode,
3396        context: &TopicAdmittanceContext,
3397        bound: Option<&CallBound>,
3398        walked: Option<&HashSet<String>>,
3399        policy: WalkBreachPolicy,
3400    ) -> Result<Validated, EngineError> {
3401        // Validate all topics are supported
3402        for topic in &tagged_beef.topics {
3403            if !self.managers.contains_key(topic) {
3404                return Err(EngineError::UnsupportedTopic(topic.clone()));
3405            }
3406        }
3407
3408        // Parse the SUBJECT from the BEEF — order-independent (`subject.rs`,
3409        // loop-2 hardening 2026-09-05): `from_beef(_, None)` takes the last
3410        // tx in wire order, which an SDK-sorted INCOMPLETE ancestry fills
3411        // with a fully-sourced ancestor; the engine then judged a hop, admitted
3412        // nothing and the pot never entered the index while its JOIN mined.
3413        let subject = {
3414            let mut b =
3415                beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
3416                    .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3417            crate::subject::subject_txid_of(&mut b)
3418        };
3419        let tx = beef_limits::transaction_from_beef(
3420            &tagged_beef.beef,
3421            subject.as_deref(),
3422            &beef_limits::ENGINE_BEEF_LIMITS,
3423        )
3424        .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3425        let txid = tx.id();
3426
3427        // SPV verification, skipped ONLY for HistoricalTxNoSpv, exactly the
3428        // reference's `if (mode !== 'historical-tx-no-spv') tx.verify(...)`.
3429        // That mode exists for GASP `finalizeGraph`, whose graphs
3430        // `validateGraphAnchor` already verified, and since bsv-low #551 that
3431        // is TRUE here: `OverlayGASPStorage::validate_graph_anchor` runs this
3432        // same check ([`verify_spv_like_the_reference`], the same tracker and
3433        // the same switch) over the ROOT node's BEEF and replays the graph
3434        // through the topic manager before a single BEEF of it reaches this
3435        // door. Before #551 the anchor check was a no-op and this skip
3436        // admitted a peer's graph on structure alone.
3437        //
3438        // A predecessor the door lands first (`walked`, the E1D delta fold,
3439        // L4) is not walked again when this submit's walks already passed
3440        // over it, and its walk trusts what they passed over.
3441        //
3442        // The walk runs under the engine's budget (bsv-low #592,
3443        // [`Engine::set_walk_budget`]): one that could not run within it is
3444        // NOT a refusal (see `verify_beef_linear`), and NOT an admission
3445        // unless the caller holds the network's accept of these bytes (the
3446        // E592 lens fold, H1, [`WalkBreachPolicy`]): "not now" before any
3447        // manager is asked, or, under `NetworkAccepted`, this submit goes on
3448        // as under `HistoricalTxNoSpv` and the report says so.
3449        let mut could_not_run = None;
3450        if mode != SubmitMode::HistoricalTxNoSpv {
3451            match walked {
3452                Some(walked) if walked.contains(&txid) => {}
3453                trusted => {
3454                    could_not_run = verify_spv_trusting(
3455                        self.chain_tracker.as_deref(),
3456                        self.verify_scripts,
3457                        &tagged_beef.beef,
3458                        &txid,
3459                        trusted.unwrap_or(&HashSet::new()),
3460                        self.walk_budget,
3461                    )
3462                    .await?;
3463                }
3464            }
3465        }
3466        if let Some(stop) = could_not_run.take() {
3467            match policy {
3468                WalkBreachPolicy::NotNow => {
3469                    warn!(
3470                        "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)",
3471                        stop.subject_txid,
3472                        stop.limb.as_str(),
3473                        stop.at_txid,
3474                        stop.subject_judged,
3475                        stop.what
3476                    );
3477                    return Err(EngineError::WalkCouldNotRun(stop));
3478                }
3479                WalkBreachPolicy::NetworkAccepted => {
3480                    warn!(
3481                        "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)",
3482                        stop.subject_txid,
3483                        stop.limb.as_str(),
3484                        stop.at_txid,
3485                        stop.subject_judged,
3486                        stop.what
3487                    );
3488                    could_not_run = Some(stop);
3489                }
3490            }
3491        }
3492
3493        let mut steak = Steak::new();
3494
3495        // =============================================================
3496        // PHASE 1: VALIDATE (read-only)
3497        // =============================================================
3498        let mut validations = Vec::new();
3499
3500        for topic in &tagged_beef.topics {
3501            validations.push(
3502                self.validate_topic(
3503                    &tx,
3504                    &txid,
3505                    topic,
3506                    tagged_beef.off_chain_values.as_deref(),
3507                    mode,
3508                    context,
3509                    bound,
3510                )
3511                .await,
3512            );
3513        }
3514
3515        // Build preliminary STEAK
3516        for v in &validations {
3517            steak.insert(v.topic.clone(), v.admittance.clone());
3518        }
3519
3520        // =============================================================
3521        // PHASE 2: BROADCAST / SHIP propagation (before mutations)
3522        // =============================================================
3523        if mode == SubmitMode::CurrentTx {
3524            // ── ARC network broadcast ──────────────────────────────────
3525            // Broadcast to miners via ARC, matching the TS Engine pattern.
3526            // Skip if the tx already has a merkle path (already mined).
3527            //
3528            // INCIDENT D1-CALLBACK-FLOOD 2026-09-01: also skip when EVERY
3529            // topic classified the tx as a dupe (already applied) — the
3530            // exact `!v.is_dupe` filter the SHIP arm below has always had,
3531            // ten lines away. Without it, every RE-PRESENT of an
3532            // already-applied tx (cron ad-sync, peer crawl, a client
3533            // retrying a default-mode /submit) re-POSTed to Arcade and
3534            // re-registered the status callback, and registrations
3535            // accumulate per POST on Arcade's side — the flood's fuel. A
3536            // genuinely new tx (any topic non-dupe) broadcasts exactly as
3537            // before.
3538            let all_topics_dupe = !validations.is_empty() && validations.iter().all(|v| v.is_dupe);
3539            if let Some(ref arc) = self.arc_broadcaster {
3540                if all_topics_dupe {
3541                    info!(
3542                        "Skipping ARC broadcast — tx already applied to every topic (re-present)"
3543                    );
3544                } else if tx.merkle_path.is_none() {
3545                    let raw_tx_hex = tx.to_hex();
3546                    match arc.broadcast(&raw_tx_hex).await {
3547                        Ok(arc_txid) => {
3548                            info!("ARC broadcast succeeded: txid={arc_txid}");
3549                        }
3550                        Err(e) => {
3551                            error!("ARC broadcast failed (non-fatal): {e}");
3552                        }
3553                    }
3554                } else {
3555                    info!("Skipping ARC broadcast — tx already has merkle proof");
3556                }
3557            }
3558
3559            // ── SHIP peer propagation ──────────────────────────────────
3560            if let Some(ref broadcaster) = self.broadcaster {
3561                let relevant_topics: Vec<String> = validations
3562                    .iter()
3563                    .filter(|v| {
3564                        !v.is_dupe && !v.failed && !v.admittance.outputs_to_admit.is_empty()
3565                    })
3566                    .map(|v| v.topic.clone())
3567                    .collect();
3568
3569                if !relevant_topics.is_empty() {
3570                    if let Some(ship_ls) = self.lookup_services.get("ls_ship") {
3571                        for topic in &relevant_topics {
3572                            let question = LookupQuestion::new(
3573                                "ls_ship",
3574                                serde_json::json!({ "topics": [topic] }),
3575                            );
3576                            match ship_ls.lookup(&question).await {
3577                                Ok(LookupResult::OutputList(refs)) => {
3578                                    for reference in &refs {
3579                                        if let Ok(Some(output)) = self
3580                                            .storage
3581                                            .find_output(
3582                                                &reference.txid,
3583                                                reference.output_index,
3584                                                None,
3585                                                None,
3586                                                false,
3587                                            )
3588                                            .await
3589                                        {
3590                                            if let Some(domain) =
3591                                                parse_ship_domain_from_script(&output.output_script)
3592                                            {
3593                                                if let Some(ref our_url) = self.config.hosting_url {
3594                                                    if domain.trim_end_matches('/')
3595                                                        == our_url.trim_end_matches('/')
3596                                                    {
3597                                                        continue;
3598                                                    }
3599                                                }
3600                                                if let Err(e) = broadcaster
3601                                                    .broadcast_to_host(&domain, tagged_beef)
3602                                                    .await
3603                                                {
3604                                                    error!(
3605                                                        "SHIP propagation to {domain} failed for topic {topic}: {e}"
3606                                                    );
3607                                                }
3608                                            }
3609                                        }
3610                                    }
3611                                }
3612                                Ok(LookupResult::Answer(_)) => {
3613                                    // SHIP only ever returns OutputList; an Answer here means
3614                                    // a misconfigured replacement service. Skip rather than
3615                                    // attempt to extract refs from a freeform/formula payload.
3616                                    error!(
3617                                        "SHIP lookup for topic {topic} returned a pre-formed \
3618                                         LookupAnswer; expected OutputList. Skipping fanout."
3619                                    );
3620                                }
3621                                Err(e) => {
3622                                    error!("SHIP lookup for topic {topic} failed: {e}");
3623                                }
3624                            }
3625                        }
3626                    }
3627                }
3628            }
3629        }
3630
3631        Ok((validations, steak, tx, txid, could_not_run))
3632    }
3633
3634    // ========================================================================
3635    // Lookup
3636    // ========================================================================
3637
3638    /// Answer a lookup query.
3639    ///
3640    /// Delegates to the appropriate LookupService, then hydrates results with BEEF.
3641    ///
3642    /// When `history_selector` is provided, each output is hydrated with its
3643    /// ancestor spend chain (via `get_utxo_history`) before building the response.
3644    /// This matches the TS Engine behavior when a history selector is configured.
3645    pub async fn lookup(
3646        &self,
3647        question: &LookupQuestion,
3648        history_selector: Option<HistorySelector>,
3649    ) -> Result<LookupAnswer, EngineError> {
3650        Ok(self.lookup_with_txids(question, history_selector).await?.0)
3651    }
3652
3653    /// Same as [`Engine::lookup`], additionally returning each hydrated
3654    /// output's txid, aligned index-for-index with the `OutputList` items
3655    /// (empty for pre-formed `Answer`s).
3656    ///
3657    /// The txid IS the storage primary key the row was just fetched by —
3658    /// handing it to the caller lets the aggregated wire serializer write it
3659    /// directly instead of re-deriving it with a full BEEF parse plus a
3660    /// double-SHA256 per output (bsv-low #289).
3661    pub async fn lookup_with_txids(
3662        &self,
3663        question: &LookupQuestion,
3664        history_selector: Option<HistorySelector>,
3665    ) -> Result<(LookupAnswer, Vec<String>), EngineError> {
3666        let service = self
3667            .lookup_services
3668            .get(&question.service)
3669            .ok_or_else(|| EngineError::LookupServiceNotFound(question.service.clone()))?;
3670
3671        // Preserve WHOSE fault it was. A lookup service reports a malformed
3672        // query as `InvalidQuery` / `Unsupported`; stringifying both into
3673        // `LookupFailed` (as this did) threw that away and reported every
3674        // caller mistake as a 500.
3675        let result = service.lookup(question).await.map_err(|e| match e {
3676            LookupServiceError::InvalidQuery(m) | LookupServiceError::Unsupported(m) => {
3677                EngineError::InvalidQuery(m)
3678            }
3679            other => EngineError::LookupFailed(other.to_string()),
3680        })?;
3681
3682        // Two paths per LookupResult:
3683        // - OutputList(refs): the LS yields outpoints; we hydrate each with
3684        //   BEEF (and optional ancestor chain via history_selector) and
3685        //   assemble LookupAnswer::OutputList.
3686        // - Answer(answer): the LS already produced the full answer
3687        //   (Freeform/Formula); pass through verbatim. The Engine does NOT
3688        //   apply history-selector hydration to a pre-formed Answer — the
3689        //   LS is presumed to have embedded whatever ancestry it wants.
3690        let refs = match result {
3691            LookupResult::OutputList(refs) => refs,
3692            LookupResult::Answer(answer) => return Ok((answer, Vec::new())),
3693        };
3694
3695        // ONE batched hydration query for the whole result set instead of a
3696        // D1 round-trip per row (the #289 N+1). Order is preserved: the
3697        // batch contract returns outputs in input-outpoint order, skipping
3698        // outpoints with no stored row. A storage error is a REAL error now
3699        // (it used to be swallowed per-row into a silently-empty list — the
3700        // "vanishing table" failure mode): an uncertain read must never
3701        // masquerade as a confidently-empty lobby.
3702        let outpoints: Vec<Outpoint> = refs
3703            .iter()
3704            .map(|r| Outpoint::new(&r.txid, r.output_index))
3705            .collect();
3706        let found = self
3707            .storage
3708            .find_outputs_by_outpoints(&outpoints, true)
3709            .await
3710            .map_err(|e| EngineError::StorageError(e.to_string()))?;
3711
3712        let mut outputs = Vec::new();
3713        let mut txids = Vec::new();
3714        for output in found {
3715            // If history selector provided, hydrate ancestor chain
3716            let final_output = if history_selector.is_some() {
3717                match self
3718                    .get_utxo_history(&output, history_selector.clone())
3719                    .await
3720                {
3721                    Ok(Some(hydrated)) => hydrated,
3722                    _ => output,
3723                }
3724            } else {
3725                output
3726            };
3727
3728            // Skip an output whose BEEF is missing OR empty: a lookup item
3729            // without decodable BEEF is useless to the client (it can only
3730            // drop it), and returning an empty-BEEF row is what made a fresh
3731            // LOW table show up in a query yet be undecodable/invisible to
3732            // opponents (the "vanishing table" — 2026-07-11). Only ever
3733            // return fully-hydrated, decodable outputs.
3734            if let Some(beef) = final_output.beef {
3735                if !beef.is_empty() {
3736                    txids.push(final_output.txid.clone());
3737                    outputs.push(OutputListItem {
3738                        beef,
3739                        output_index: final_output.output_index,
3740                        context: None,
3741                    });
3742                }
3743            }
3744        }
3745
3746        Ok((LookupAnswer::OutputList { outputs }, txids))
3747    }
3748
3749    // ========================================================================
3750    // Metadata
3751    // ========================================================================
3752
3753    // ========================================================================
3754    // UTXO History
3755    // ========================================================================
3756
3757    /// Traverse and return the history of a UTXO.
3758    ///
3759    /// If no `history_selector` is provided, returns the output as-is.
3760    /// If a depth selector is provided, includes up to N levels of ancestor
3761    /// transactions by embedding them as source_transactions in the BEEF.
3762    pub async fn get_utxo_history(
3763        &self,
3764        output: &Output,
3765        history_selector: Option<HistorySelector>,
3766    ) -> Result<Option<Output>, EngineError> {
3767        let Some(selector) = history_selector else {
3768            return Ok(Some(output.clone()));
3769        };
3770
3771        // Verify BEEF exists before attempting hydration
3772        if output.beef.is_none() {
3773            return Err(EngineError::Other(
3774                "Output must have associated transaction BEEF!".into(),
3775            ));
3776        }
3777
3778        match self.hydrate_utxo_history(output, &selector, 0).await {
3779            Ok(Some(hydrated_output)) => Ok(Some(hydrated_output)),
3780            Ok(None) => Ok(None),
3781            Err(e) => Err(EngineError::Other(format!(
3782                "Error retrieving UTXO history: {e}"
3783            ))),
3784        }
3785    }
3786
3787    /// Recursive UTXO history hydration.
3788    fn hydrate_utxo_history<'a>(
3789        &'a self,
3790        output: &'a Output,
3791        selector: &'a HistorySelector,
3792        current_depth: u32,
3793    ) -> std::pin::Pin<
3794        Box<dyn std::future::Future<Output = Result<Option<Output>, EngineError>> + 'a>,
3795    > {
3796        Box::pin(async move {
3797            let beef_data = output.beef.as_ref().ok_or_else(|| {
3798                EngineError::Other("Output must have associated transaction BEEF!".into())
3799            })?;
3800
3801            // Check if we should traverse at this depth
3802            let should_traverse = match selector {
3803                HistorySelector::Depth(max_depth) => current_depth <= *max_depth,
3804            };
3805
3806            if !should_traverse {
3807                return Ok(None);
3808            }
3809
3810            // Start from the STORED BEEF, not from a re-parsed subject.
3811            //
3812            // #4: this used to be `Transaction::from_beef(beef_data, None)` followed
3813            // by `tx.to_beef(true)`, which keeps only the subject transaction and
3814            // therefore DESTROYS the stored BUMPs. Measured on prod before the fix:
3815            // `ls_low` byGameId `73de6e48…` returned 1,172 B / 1 tx / 1 bump (PROVEN)
3816            // with no header, and 676 B / 1 tx / 0 bumps with `x-history-depth: 3` —
3817            // a caller asking for MORE provenance got back a BEEF no wallet can
3818            // verify, silently, behind a 200.
3819            //
3820            // Accumulating into the stored BEEF fixes both halves: its own bumps
3821            // survive, and so does any ancestry that arrived inside the submitted
3822            // bytes but was never admitted as an output of its own (a foreign
3823            // parent), which the `outputs_consumed` walk below cannot see.
3824            let mut acc = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3825                .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3826
3827            // For each consumed output, recursively hydrate and embed as source transaction
3828            for consumed in &output.outputs_consumed {
3829                if let Ok(Some(child_output)) = self
3830                    .storage
3831                    .find_output(&consumed.txid, consumed.output_index, None, None, true)
3832                    .await
3833                {
3834                    if let Ok(Some(hydrated_child)) = self
3835                        .hydrate_utxo_history(&child_output, selector, current_depth + 1)
3836                        .await
3837                    {
3838                        // MERGE the hydrated ancestor in, rather than embedding it
3839                        // as a `source_transaction` on a re-parsed subject: a merge
3840                        // carries the child's own BUMPs across, which is the whole
3841                        // point of asking for history. An unparseable child is
3842                        // skipped — never allowed to poison the accumulator.
3843                        if let Some(ref child_beef) = hydrated_child.beef {
3844                            if let Ok(child) = Beef::from_binary(child_beef) {
3845                                acc.merge_beef(&child);
3846                            }
3847                        }
3848                    }
3849                }
3850            }
3851
3852            Ok(Some(Output {
3853                beef: Some(acc.to_binary()),
3854                ..output.clone()
3855            }))
3856        })
3857    }
3858
3859    // ========================================================================
3860    // Metadata
3861    // ========================================================================
3862
3863    pub async fn list_topic_managers(&self) -> HashMap<String, ServiceMetadata> {
3864        let mut result = HashMap::new();
3865        for (name, manager) in &self.managers {
3866            let meta = manager.get_metadata().await;
3867            result.insert(name.clone(), meta);
3868        }
3869        result
3870    }
3871
3872    /// List all registered lookup services with their metadata.
3873    pub async fn list_lookup_service_providers(&self) -> HashMap<String, ServiceMetadata> {
3874        let mut result = HashMap::new();
3875        for (name, service) in &self.lookup_services {
3876            let meta = service.get_metadata().await;
3877            result.insert(name.clone(), meta);
3878        }
3879        result
3880    }
3881
3882    /// Get documentation for a specific topic manager.
3883    pub async fn get_documentation_for_topic_manager(&self, name: &str) -> String {
3884        match self.managers.get(name) {
3885            Some(manager) => manager.get_documentation().await,
3886            None => "No documentation found!".to_string(),
3887        }
3888    }
3889
3890    /// Get documentation for a specific lookup service.
3891    pub async fn get_documentation_for_lookup_service(&self, name: &str) -> String {
3892        match self.lookup_services.get(name) {
3893            Some(service) => service.get_documentation().await,
3894            None => "No documentation found!".to_string(),
3895        }
3896    }
3897
3898    // ========================================================================
3899    // GASP endpoints
3900    // ========================================================================
3901
3902    /// Respond to a GASP initial sync request.
3903    ///
3904    /// Returns UTXOs for the given topic since the requested score.
3905    ///
3906    /// The caller-supplied `limit` is CLAMPED to
3907    /// [`Engine::SYNC_RESPONSE_MAX_LIMIT`] (and defaulted when absent):
3908    /// `/requestSyncResponse` is a PUBLIC route, and an absent limit used to
3909    /// mean an unbounded scan of the outputs table (bsv-low #291). Bounding
3910    /// is lossless for sync correctness because the initiator
3911    /// ([`crate::gasp::GASPSync`]) pages for as long as its `since` score
3912    /// cursor advances — a truncated page is fetched by its NEXT request
3913    /// within the same run (gate finding M1: pagination must not demand a
3914    /// full page, which this clamp can make impossible).
3915    pub async fn provide_foreign_sync_response(
3916        &self,
3917        request: &GASPInitialRequest,
3918        topic: &str,
3919    ) -> Result<GASPInitialResponse, EngineError> {
3920        let limit = Self::clamp_sync_limit(request.limit);
3921        let outputs = self
3922            .storage
3923            .find_utxos_for_topic(topic, Some(request.since as f64), Some(limit), false)
3924            .await
3925            .map_err(|e| EngineError::StorageError(e.to_string()))?;
3926
3927        Ok(GASPInitialResponse {
3928            utxo_list: outputs
3929                .iter()
3930                .map(|o| GASPOutput {
3931                    txid: o.txid.clone(),
3932                    output_index: o.output_index,
3933                    score: o.score.unwrap_or(0.0),
3934                })
3935                .collect(),
3936            since: request.since,
3937        })
3938    }
3939
3940    /// Provide a GASPNode for a specific transaction within a graph.
3941    ///
3942    /// Searches the BEEF tree of the root output (identified by graphID) for
3943    /// the requested txid. If not found in the BEEF tree, recurses through
3944    /// outputsConsumed in storage.
3945    pub async fn provide_foreign_gasp_node(
3946        &self,
3947        graph_id: &str,
3948        txid: &str,
3949        output_index: u32,
3950    ) -> Result<GASPNode, EngineError> {
3951        let root_outpoint = Outpoint::from_graph_id(graph_id)
3952            .ok_or_else(|| EngineError::Other(format!("Invalid graphID: {graph_id}")))?;
3953
3954        let root_output = self
3955            .storage
3956            .find_output(
3957                &root_outpoint.txid,
3958                root_outpoint.output_index,
3959                None,
3960                None,
3961                true,
3962            )
3963            .await
3964            .map_err(|e| EngineError::StorageError(e.to_string()))?
3965            .ok_or(EngineError::NodeNotFound)?;
3966
3967        self.hydrate_gasp_node(&root_output, graph_id, txid, output_index)
3968            .await
3969    }
3970
3971    /// Recursively search a BEEF tree for a specific txid and return a GASPNode.
3972    fn hydrate_gasp_node<'a>(
3973        &'a self,
3974        output: &'a Output,
3975        graph_id: &'a str,
3976        txid: &'a str,
3977        output_index: u32,
3978    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GASPNode, EngineError>> + 'a>>
3979    {
3980        Box::pin(async move {
3981            let beef_data = output.beef.as_ref().ok_or(EngineError::NodeNotFound)?;
3982
3983            // The stored body's SUBJECT by the same order-independent rule
3984            // (`subject.rs`) — a plain stored body's last tx can be an ancestor.
3985            let root = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3986                .ok()
3987                .and_then(|mut b| crate::subject::subject_txid_of(&mut b));
3988            let root_tx = beef_limits::transaction_from_beef(
3989                beef_data,
3990                root.as_deref(),
3991                &beef_limits::STORED_BEEF_LIMITS,
3992            )
3993            .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3994
3995            // Search the transaction tree for the requested txid
3996            if let Some(node) = Self::search_tx_tree(&root_tx, graph_id, txid, output_index) {
3997                return Ok(node);
3998            }
3999
4000            // Fallback: try parsing BEEF with the target txid directly
4001            // (some BEEF structures have the tx in the list but not linked as source_transaction)
4002            if let Some(node) = Self::search_beef_for_txid(beef_data, graph_id, txid, output_index)
4003            {
4004                return Ok(node);
4005            }
4006
4007            // Not found in BEEF tree — recurse through outputsConsumed in storage
4008            for consumed in &output.outputs_consumed {
4009                if let Ok(Some(consumed_output)) = self
4010                    .storage
4011                    .find_output(&consumed.txid, consumed.output_index, None, None, true)
4012                    .await
4013                {
4014                    if let Ok(node) = self
4015                        .hydrate_gasp_node(&consumed_output, graph_id, txid, output_index)
4016                        .await
4017                    {
4018                        return Ok(node);
4019                    }
4020                }
4021            }
4022
4023            // The graph root is known but neither its BEEF nor anything it
4024            // consumed holds the requested transaction. That is a definite
4025            // "not held", the same answer as an unknown root: the reference
4026            // throws `Unable to find output associated with your request!`
4027            // here and its route answers 400. A syncing peer reads the 400 as
4028            // `GASPError::NodeNotFound` and prunes a decoy input on it (the D8
4029            // rule, `GASPSync::process_incoming_node`); a 500 would read as a
4030            // fault of the moment and strand the chain behind the decoy.
4031            Err(EngineError::NodeNotFound)
4032        })
4033    }
4034
4035    /// Search a Transaction tree (via sourceTransaction links) for a specific txid.
4036    fn search_tx_tree(
4037        tx: &Transaction,
4038        graph_id: &str,
4039        target_txid: &str,
4040        output_index: u32,
4041    ) -> Option<GASPNode> {
4042        let current_txid = tx.id();
4043
4044        if current_txid == target_txid {
4045            let mut node = GASPNode {
4046                graph_id: graph_id.to_string(),
4047                raw_tx: tx.to_hex(),
4048                output_index,
4049                proof: None,
4050                tx_metadata: None,
4051                output_metadata: None,
4052                inputs: None,
4053            };
4054            if let Some(ref merkle_path) = tx.merkle_path {
4055                node.proof = Some(merkle_path.to_hex());
4056            }
4057            return Some(node);
4058        }
4059
4060        // Recurse into inputs via source_transaction
4061        for input in &tx.inputs {
4062            if let Some(ref source_tx) = input.source_transaction {
4063                if let Some(node) =
4064                    Self::search_tx_tree(source_tx, graph_id, target_txid, output_index)
4065                {
4066                    return Some(node);
4067                }
4068            }
4069        }
4070
4071        None
4072    }
4073
4074    /// Alternative search: try parsing the BEEF with the target txid directly.
4075    /// Some BEEF structures may not link source_transaction on inputs but
4076    /// still contain the transaction in the BEEF's transaction list.
4077    fn search_beef_for_txid(
4078        beef_data: &[u8],
4079        graph_id: &str,
4080        target_txid: &str,
4081        output_index: u32,
4082    ) -> Option<GASPNode> {
4083        // Try parsing BEEF with the specific txid
4084        if let Ok(tx) = beef_limits::transaction_from_beef(
4085            beef_data,
4086            Some(target_txid),
4087            &beef_limits::STORED_BEEF_LIMITS,
4088        ) {
4089            if tx.id() == target_txid {
4090                let mut node = GASPNode {
4091                    graph_id: graph_id.to_string(),
4092                    raw_tx: tx.to_hex(),
4093                    output_index,
4094                    proof: None,
4095                    tx_metadata: None,
4096                    output_metadata: None,
4097                    inputs: None,
4098                };
4099                if let Some(ref merkle_path) = tx.merkle_path {
4100                    node.proof = Some(merkle_path.to_hex());
4101                }
4102                return Some(node);
4103            }
4104        }
4105        None
4106    }
4107
4108    // ========================================================================
4109    // Merkle proof handling
4110    // ========================================================================
4111
4112    /// Handle a new merkle proof for a transaction.
4113    ///
4114    /// When a transaction gets mined, ARC calls back with the merkle proof.
4115    /// This method:
4116    /// 1. Finds all outputs for the txid in storage
4117    /// 2. Parses the BEEF, updates the merkle path in the transaction tree
4118    /// 3. Serializes updated BEEF back to storage
4119    /// 4. Recursively updates the consumedBy chain (outputs that spent this one)
4120    /// 5. Updates blockHeight on the outputs
4121    pub async fn handle_new_merkle_proof(
4122        &self,
4123        txid: &str,
4124        proof_hex: &str,
4125        block_height: Option<u32>,
4126    ) -> Result<(), EngineError> {
4127        let outputs = self
4128            .storage
4129            .find_outputs_for_transaction(txid, true)
4130            .await
4131            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4132
4133        if outputs.is_empty() {
4134            return Err(EngineError::Other(
4135                "Could not find matching transaction outputs for proof ingest!".into(),
4136            ));
4137        }
4138
4139        // Parse merkle proof if provided
4140        let proof = if proof_hex.is_empty() {
4141            None
4142        } else {
4143            Some(
4144                beef_limits::merkle_path_from_hex(proof_hex, beef_limits::PUSH_PROOF_MAX_BYTES)
4145                    .map_err(|e| EngineError::Other(format!("Invalid merkle proof hex: {e}")))?,
4146            )
4147        };
4148
4149        for output in &outputs {
4150            // Stitch the proof into the STORED BEEF (#284) — never rebuild it
4151            // around a re-picked subject; see stitch_proof_into_stored_beef.
4152            if let (Some(ref beef_data), Some(ref proof)) = (&output.beef, &proof) {
4153                if let Some(new_beef) = Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4154                {
4155                    let _ = self
4156                        .storage
4157                        .update_transaction_beef(&output.txid, &new_beef)
4158                        .await;
4159                }
4160            }
4161
4162            // Update block height
4163            if let Some(height) = block_height {
4164                let _ = self
4165                    .storage
4166                    .update_output_block_height(
4167                        &output.txid,
4168                        output.output_index,
4169                        &output.topic,
4170                        height,
4171                    )
4172                    .await;
4173            }
4174
4175            // Recursively update consumedBy chain
4176            for consuming in &output.consumed_by {
4177                if let Ok(consumed_outputs) = self
4178                    .storage
4179                    .find_outputs_for_transaction(&consuming.txid, true)
4180                    .await
4181                {
4182                    for consumed_output in &consumed_outputs {
4183                        // The consuming row's BEEF carries this tx as an
4184                        // ancestor — stitch the same bump in there too, again
4185                        // without rebuilding the tx set (#284: this branch is
4186                        // the one that was observed replacing a head-spend's
4187                        // stored BEEF with parent ancestry only).
4188                        if let (Some(ref beef_data), Some(ref proof)) =
4189                            (&consumed_output.beef, &proof)
4190                        {
4191                            if let Some(new_beef) =
4192                                Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4193                            {
4194                                let _ = self
4195                                    .storage
4196                                    .update_transaction_beef(&consumed_output.txid, &new_beef)
4197                                    .await;
4198                            }
4199                        }
4200                    }
4201                }
4202            }
4203        }
4204
4205        Ok(())
4206    }
4207
4208    /// Complete missing merkle proofs for stored, confirmed transactions.
4209    ///
4210    /// The `/submit`-admitted ingest path stores a proofless BEEF for each
4211    /// transaction (the submitter does not yet have a merkle proof at submit
4212    /// time). When such a transaction later mines, nothing in the overlay
4213    /// re-fetches its proof, so its stored BEEF stays proofless forever — and a
4214    /// frontend that trims `inputBEEF` to `{source tx + its BUMP}` for a P2PKH
4215    /// payment-import spend has no BUMP to trim to, hanging the import (#130).
4216    ///
4217    /// This pass closes that gap from chain (the one blessed server-side WoC
4218    /// use). It:
4219    /// 1. Pulls a bounded page (`limit`) of stored `(txid, beef)` from storage.
4220    /// 2. Parses each BEEF and keeps only those whose target tx still lacks a
4221    ///    merkle proof (`Beef::find_txid(txid).has_proof() == false`).
4222    /// 3. For each proofless candidate, fetches its BUMP via the configured
4223    ///    [`AncestorFetcher`](crate::gasp::AncestorFetcher). If a proof comes
4224    ///    back, it calls [`Self::handle_new_merkle_proof`], which stitches the
4225    ///    BUMP into the stored BEEF (D1 + R2 mirror), updates output block
4226    ///    height, and recurses the `consumedBy` chain.
4227    /// 4. Skips (does NOT error) txs the fetcher can't prove yet (still
4228    ///    unconfirmed) — those are simply retried on a later tick.
4229    ///
4230    /// **No-op when no [`AncestorFetcher`] is configured** — production-safe
4231    /// default (the same opt-in switch as GASP ancestor hydration). Bounded by
4232    /// `limit` (a per-tick budget, like the platform fetcher's own budget).
4233    ///
4234    /// `min_age_secs` is the PUSH-PRIMARY BACKSTOP gate (bsv-low #228 /
4235    /// arcade#259): since the Arcade MINED webhook (`/arc-ingest`) pushes a
4236    /// verified proof ~150 ms after a tx mines, this poll pass is a BACKSTOP,
4237    /// not the primary source — rows stored less than `min_age_secs` ago are
4238    /// skipped entirely (their push is still expected). Pass `0` to disable
4239    /// the gate (poll everything — the pre-#228 behaviour, and the degradation
4240    /// mode if pushes stop: an old-enough row is ALWAYS polled, so webhook
4241    /// loss degrades to polling, never to nothing). Unknown-age rows are
4242    /// always eligible (fail-safe — see the storage-trait doc).
4243    pub async fn complete_missing_proofs(
4244        &self,
4245        limit: u64,
4246        min_age_secs: u64,
4247    ) -> Result<ProofCompletionSummary, EngineError> {
4248        let mut summary = ProofCompletionSummary::default();
4249
4250        // Production-safe: with no fetcher there is no chain source to complete
4251        // from, so this is a pure no-op (matches GASP-hydration opt-in).
4252        let Some(fetcher) = self.ancestor_fetcher.clone() else {
4253            return Ok(summary);
4254        };
4255
4256        let candidates = self
4257            .storage
4258            .find_transactions_for_proof_check(limit, min_age_secs)
4259            .await
4260            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4261        summary.scanned = candidates.len();
4262
4263        for cand in candidates {
4264            // Read the stored BEEF for the subject's OWN bump by streaming
4265            // folds (one element in hand, no `Beef` built: NL-6); skip
4266            // anything unparseable (don't error the whole pass on one bad row).
4267            let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4268                Ok(b) => b,
4269                Err(e) => {
4270                    warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4271                    continue;
4272                }
4273            };
4274
4275            // Already structurally proven? The stored BEEF carries a merkle bump
4276            // for the target tx, but its backend `has_proof` flag may still be 0
4277            // — EITHER a legitimate GASP-synced proof written before overlay
4278            // migration 0010 (which defaulted every existing row to 0), OR an
4279            // admit-time bump that was NEVER SPV-verified (`/submit` skips SPV on
4280            // historical topics) or is outright forged. Serve-time BEEF trimming
4281            // trusts the `has_proof` flag, so latching one on STRUCTURE ALONE
4282            // would let a forged bump be trimmed on (#192/#193 MEDIUM). Re-verify
4283            // the STORED bump against chaintracks (via the fetcher — its header
4284            // source is the only arbiter of a merkle root). Genuine → latch
4285            // (fast, no re-fetch). Otherwise DON'T trust it: fall through to the
4286            // fetch+stitch path below, which overwrites the bump with a
4287            // chaintracks-verified one (or leaves the row proofless for retry).
4288            // Fail-closed throughout.
4289            // the tx's OWN bump (its `bump_index`), never `find_bump`
4290            // (the FIRST bump containing the txid — the stale one after a
4291            // same-height reorg; review HIGH-1).
4292            if let Some(bump_hex) = own_bump
4293                .as_ref()
4294                .map(bsv_rs::transaction::MerklePath::to_hex)
4295            {
4296                if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4297                    // Idempotent + best-effort: a failure here is logged, not
4298                    // fatal — the row simply lingers one more tick.
4299                    // bsv-low M19 R2 round 3 (review MED-2): latch WITH the
4300                    // anchor height (from the tx's OWN verified bump) so the
4301                    // revalidation sweep's transactions leg can window this
4302                    // row — `mark_transaction_proven` alone left every
4303                    // fast-path row anchorless, in no window forever.
4304                    let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4305                        .ok()
4306                        .map(|mp| u64::from(mp.block_height));
4307                    if let Err(e) = self
4308                        .storage
4309                        .mark_transaction_proven_at(&cand.txid, height)
4310                        .await
4311                    {
4312                        warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4313                    } else {
4314                        summary.already_proven += 1;
4315                    }
4316                    continue;
4317                }
4318                warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4319            }
4320            // Unverifiable structural bump → treat as proofless (fall through).
4321            summary.proofless += 1;
4322
4323            // Fetch this tx's own VERIFIED bump from chain — PROOF ONLY (the raw
4324            // is already in the stored BEEF, so no redundant raw fetch; #192/#193
4325            // FIX 2). A tx with no verified proof yet (still unconfirmed, or an
4326            // unverifiable/forged courier response) is skipped, not errored.
4327            let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4328                summary.still_unconfirmed += 1;
4329                continue;
4330            };
4331
4332            // Pull the block height out of the BUMP so the output rows update too.
4333            let block_height =
4334                beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4335                    .ok()
4336                    .map(|mp| mp.block_height);
4337
4338            match self
4339                .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4340                .await
4341            {
4342                Ok(()) => summary.completed += 1,
4343                Err(e) => {
4344                    warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4345                    summary.stitch_failed += 1;
4346                }
4347            }
4348        }
4349
4350        info!(
4351            scanned = summary.scanned,
4352            proofless = summary.proofless,
4353            completed = summary.completed,
4354            still_unconfirmed = summary.still_unconfirmed,
4355            fetch_failed = summary.fetch_failed,
4356            stitch_failed = summary.stitch_failed,
4357            already_proven = summary.already_proven,
4358            "[PROOF COMPLETION] pass complete"
4359        );
4360
4361        Ok(summary)
4362    }
4363
4364    /// Recursively update merkle paths in a transaction's input tree.
4365    ///
4366    /// If the transaction's id matches txid, set its merkle_path.
4367    /// Otherwise, recurse into sourceTransactions of each input.
4368    /// Stitch a verified BUMP for `txid` into a STORED BEEF, preserving every
4369    /// transaction and every other proof in it (#284).
4370    ///
4371    /// The old path round-tripped the stored bytes through
4372    /// `Transaction::from_beef(bytes, None)` -> `to_beef(true)`, and
4373    /// `from_beef(_, None)` picks **`txs.last()`** — it ignores the atomic
4374    /// subject pointer entirely. Whenever the subject was not the last tx in
4375    /// wire order, a PARENT was picked, `to_beef(true)` then serialized that
4376    /// parent's world, and the rewrite dropped the subject transaction from its
4377    /// own stored BEEF — observed live on every pf head-spend (list / reprice /
4378    /// delist / buy) the moment it mined, breaking the NEXT spend on that name
4379    /// (zanaadu#284). `update_input_proofs`' "already has a proof — update it"
4380    /// arm could even stamp the child's bump onto that mis-picked parent.
4381    /// This is the same destroy-the-stored-BEEF class the #4 fix note above
4382    /// (`hydrate_utxo_history`) already documents for lookup hydration.
4383    ///
4384    /// Operating on [`Beef`] directly has none of those failure modes:
4385    /// `merge_bump` dedupes against existing bumps by (height, root) and
4386    /// assigns the bump ONLY to transactions that appear as flagged txid
4387    /// leaves, so stitching tx X's proof can never mislabel tx Y, and the tx
4388    /// set is never rebuilt at all.
4389    ///
4390    /// Returns `None` when the bytes do not parse or `txid` is not in this
4391    /// BEEF (nothing to stitch — e.g. a consuming row whose ancestry got
4392    /// trimmed), so callers skip the write instead of writing garbage.
4393    ///
4394    /// bsv-low M19 R2 round 3 (review HIGH-1): a same-height DIFFERENT-root
4395    /// proof (the 2026-09-07 reorg shape) does NOT combine in `merge_bump`
4396    /// (the roots differ) — it is pushed as a SECOND bump, and
4397    /// `update_bump_indices` assigns it only to txs whose `bump_index` is
4398    /// `None`, so the reorged subject keeps its index on the STALE bump.
4399    /// Left alone, `has_proof`/`proofHeight` and every `find_txid(txid)
4400    /// .bump_index()` reader then keep pointing at the orphan bump, and the
4401    /// row refutes again next sweep pass forever. So after the merge, FORCE
4402    /// the subject onto the bump that actually proves it, then drop any bump
4403    /// no transaction references (the orphan's, now unreferenced).
4404    pub fn stitch_proof_into_stored_beef(
4405        stored: &[u8],
4406        txid: &str,
4407        proof: &bsv_rs::transaction::MerklePath,
4408    ) -> Option<Vec<u8>> {
4409        let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4410        beef.find_txid(txid)?;
4411        let i = beef.merge_bump(proof.clone());
4412        // Does the merged bump actually prove `txid` (a flagged txid leaf)?
4413        // (It always does for a completion proof; the guard keeps a stray
4414        // caller from mis-anchoring a subject onto an ancestor's bump.)
4415        let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4416            b.path.first().is_some_and(|leaves| {
4417                leaves
4418                    .iter()
4419                    .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4420            })
4421        });
4422        if proves_subject {
4423            if let Some(tx) = beef.find_txid_mut(txid) {
4424                tx.set_bump_index(Some(i));
4425            }
4426        }
4427        Self::gc_unreferenced_bumps(&mut beef);
4428        Some(beef.to_binary())
4429    }
4430
4431    /// Drop every BUMP no transaction references and re-index the survivors
4432    /// (bsv-low M19 R2 round 3, review HIGH-1): after the subject is moved
4433    /// off an orphan bump, that bump is usually dead weight the next reader
4434    /// would still `find_bump`. A bump still referenced by ANOTHER tx (a
4435    /// sibling mined in the same block) is kept.
4436    fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4437        let referenced: std::collections::HashSet<usize> = beef
4438            .txs
4439            .iter()
4440            .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4441            .collect();
4442        if referenced.len() == beef.bumps.len() {
4443            return;
4444        }
4445        let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4446        let mut kept = Vec::with_capacity(referenced.len());
4447        for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4448            if referenced.contains(&old) {
4449                remap[old] = Some(kept.len());
4450                kept.push(bump);
4451            }
4452        }
4453        beef.bumps = kept;
4454        for tx in &mut beef.txs {
4455            if let Some(old) = tx.bump_index() {
4456                tx.set_bump_index(remap[old]);
4457            }
4458        }
4459    }
4460
4461    // ========================================================================
4462    // Advertisement sync
4463    // ========================================================================
4464
4465    /// Sync SHIP/SLAP advertisements with configured managers and services.
4466    ///
4467    /// Creates missing advertisements and revokes stale ones. Returns a
4468    /// [`SyncAdvertisementsReport`] so a create/submit failure is VISIBLE to
4469    /// the caller (bsv-low #320 defect 3a): this method used to swallow both
4470    /// into `error!` and return `Ok(())`, so `/admin/syncAdvertisements`
4471    /// reported `success` while zero advertisements were admitted locally —
4472    /// a caller could not tell a converged no-op from a silent failure, and
4473    /// every cycle re-created the full ad set.
4474    pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4475        let mut report = SyncAdvertisementsReport::default();
4476
4477        let Some(advertiser) = &self.advertiser else {
4478            return Ok(report); // No advertiser configured
4479        };
4480
4481        let hosting_url = match &self.config.hosting_url {
4482            Some(url) if !url.is_empty() => url.clone(),
4483            _ => return Ok(report), // No hosting URL
4484        };
4485
4486        // Get configured topics and services
4487        let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4488        let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4489
4490        if self.config.suppress_default_sync_advertisements {
4491            configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4492            configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4493        }
4494
4495        // Fetch current advertisements. A read failure REFUSES creation
4496        // (#320 M2): swallowing it into "current ads = none" made a
4497        // transient D1/storage fault re-create — and re-pay for — the
4498        // entire advertisement set, reported as a legitimate `to_create`.
4499        let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4500            Ok(ads) => ads,
4501            Err(e) => {
4502                error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4503                report.lookup_error = Some(format!("ship: {e}"));
4504                return Ok(report);
4505            }
4506        };
4507        let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4508            Ok(ads) => ads,
4509            Err(e) => {
4510                error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4511                report.lookup_error = Some(format!("slap: {e}"));
4512                return Ok(report);
4513            }
4514        };
4515
4516        // Determine what to create
4517        let ships_to_create: Vec<AdvertisementData> = configured_topics
4518            .iter()
4519            .filter(|topic| {
4520                !current_ship
4521                    .iter()
4522                    .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4523            })
4524            .map(|topic| AdvertisementData {
4525                protocol: Protocol::Ship,
4526                topic_or_service_name: topic.clone(),
4527            })
4528            .collect();
4529
4530        let slaps_to_create: Vec<AdvertisementData> = configured_services
4531            .iter()
4532            .filter(|service| {
4533                !current_slap
4534                    .iter()
4535                    .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4536            })
4537            .map(|service| AdvertisementData {
4538                protocol: Protocol::Slap,
4539                topic_or_service_name: service.clone(),
4540            })
4541            .collect();
4542
4543        // Determine what to revoke
4544        let ships_to_revoke: Vec<Advertisement> = current_ship
4545            .into_iter()
4546            .filter(|a| !configured_topics.contains(&a.topic_or_service))
4547            .collect();
4548
4549        let slaps_to_revoke: Vec<Advertisement> = current_slap
4550            .into_iter()
4551            .filter(|a| !configured_services.contains(&a.topic_or_service))
4552            .collect();
4553
4554        // Create new advertisements
4555        let mut all_to_create = ships_to_create;
4556        all_to_create.extend(slaps_to_create);
4557        report.to_create = all_to_create.len();
4558        if !all_to_create.is_empty() {
4559            match advertiser.create_advertisements(&all_to_create).await {
4560                Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4561                    Ok(steak) => {
4562                        for (topic, instructions) in &steak {
4563                            report
4564                                .admitted
4565                                .insert(topic.clone(), instructions.outputs_to_admit.len());
4566                        }
4567                    }
4568                    Err(e) => {
4569                        error!("Failed to submit new advertisements: {e}");
4570                        report.submit_error = Some(e.to_string());
4571                    }
4572                },
4573                Err(e) => {
4574                    error!("Failed to create advertisements: {e}");
4575                    report.create_error = Some(e.to_string());
4576                }
4577            }
4578        }
4579
4580        // Revoke stale advertisements
4581        let mut all_to_revoke = ships_to_revoke;
4582        all_to_revoke.extend(slaps_to_revoke);
4583        report.to_revoke = all_to_revoke.len();
4584        if !all_to_revoke.is_empty() {
4585            match advertiser.revoke_advertisements(&all_to_revoke).await {
4586                // An advertiser that declines to build a revocation (e.g. the
4587                // CF advertiser's documented v1 no-op) returns an EMPTY
4588                // TaggedBEEF — submitting it would only manufacture a parse
4589                // error. Skip, but say so in the report (#320 L2): the stale
4590                // ads stay on-chain until they age out.
4591                Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4592                    report.revoke_skipped = all_to_revoke.len();
4593                }
4594                Ok(tagged_beef) => {
4595                    if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4596                        error!("Failed to submit revocation: {e}");
4597                        report.revoke_submit_error = Some(e.to_string());
4598                    }
4599                }
4600                Err(e) => {
4601                    error!("Failed to revoke advertisements: {e}");
4602                    report.revoke_error = Some(e.to_string());
4603                }
4604            }
4605        }
4606
4607        Ok(report)
4608    }
4609
4610    // ========================================================================
4611    // Deep deletion
4612    // ========================================================================
4613
4614    /// Recursively delete a UTXO and all stale consumed inputs.
4615    ///
4616    /// Only deletes if the output has no remaining consumers (consumedBy is empty).
4617    /// Then recurses into outputsConsumed, removing the deleted output from their
4618    /// consumedBy lists and deleting them if they become unreferenced.
4619    #[allow(dead_code)] // Used by submit() once BEEF parsing is wired up
4620    ///
4621    /// `bound` is a finalize submit's [`CallBound`] over each call here
4622    /// (bsv-low #559); `None` everywhere else.
4623    fn delete_utxo_deep<'a>(
4624        &'a self,
4625        output: &'a Output,
4626        bound: Option<&'a CallBound>,
4627    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4628        Box::pin(async move {
4629            // Only delete if nothing consumes this output
4630            if output.consumed_by.is_empty() {
4631                stored(
4632                    bound,
4633                    self.storage
4634                        .delete_output(&output.txid, output.output_index, &output.topic),
4635                )
4636                .await
4637                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4638
4639                // Notify lookup services. A hook error is dropped, not
4640                // reported (parity: the reference catches it, best effort).
4641                for ls in self.lookup_services.values() {
4642                    let _ = hooked(
4643                        bound,
4644                        ls.output_no_longer_retained_in_history(
4645                            &output.txid,
4646                            output.output_index,
4647                            &output.topic,
4648                        ),
4649                    )
4650                    .await;
4651                }
4652            }
4653
4654            // Recurse into consumed outputs
4655            for consumed in &output.outputs_consumed {
4656                if let Ok(Some(mut stale_output)) = stored(
4657                    bound,
4658                    self.storage.find_output(
4659                        &consumed.txid,
4660                        consumed.output_index,
4661                        Some(&output.topic),
4662                        None,
4663                        false,
4664                    ),
4665                )
4666                .await
4667                {
4668                    // Remove the deleted output from consumedBy
4669                    stale_output.consumed_by.retain(|c| {
4670                        !(c.txid == output.txid && c.output_index == output.output_index)
4671                    });
4672
4673                    stored(
4674                        bound,
4675                        self.storage.update_consumed_by(
4676                            &consumed.txid,
4677                            consumed.output_index,
4678                            &output.topic,
4679                            &stale_output.consumed_by,
4680                        ),
4681                    )
4682                    .await
4683                    .map_err(|e| EngineError::StorageError(e.to_string()))?;
4684
4685                    // Recurse
4686                    let _ = self.delete_utxo_deep(&stale_output, bound).await;
4687                }
4688            }
4689
4690            Ok(())
4691        }) // Box::pin
4692    }
4693
4694    // ========================================================================
4695    // Eviction
4696    // ========================================================================
4697
4698    /// Evict a specific outpoint from the overlay.
4699    ///
4700    /// 1. Deletes the output from storage for the given topic.
4701    /// 2. Notifies all lookup services via `output_evicted()`.
4702    ///
4703    /// If no topic is specified, finds all outputs for the txid and evicts the
4704    /// matching outputIndex across every topic.
4705    ///
4706    /// Matches TS OverlayExpress `/admin/evictOutpoint` (lines 974-1004).
4707    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): RE-NOTIFY every lookup service
4708    /// of the outputs the engine ALREADY admitted for (`txid`, `topic`).
4709    ///
4710    /// Phase-1 dedup means a (txid, topic) once recorded in
4711    /// `applied_transactions` never re-enters through `/submit`, so a lookup
4712    /// service that missed — or mis-parsed — its admit notification could
4713    /// never be told again (the loop-2 subject trap: the pot index's admit
4714    /// hook shape-checked a wire-last hop and wrote no record while the engine
4715    /// had admitted the pot). This replays `output_admitted_by_topic` from the
4716    /// engine's OWN stored outputs (with their BEEF) and the subject-NAMED body
4717    /// exactly as `submit_with_report` sends it. It validates nothing anew
4718    /// (the topic manager judged these outputs at admit), writes none of the
4719    /// engine's tables, and is idempotent for every lookup service whose
4720    /// writes are (the pot index's `store_record` is insert-if-absent for the
4721    /// spend fields — re-admission never regresses spend state).
4722    /// `outputs == 0` means the engine never admitted this txid on this topic:
4723    /// nothing to re-notify — the caller re-SUBMITS instead.
4724    pub async fn renotify_admitted(
4725        &self,
4726        txid: &str,
4727        topic: &str,
4728    ) -> Result<RenotifyReport, EngineError> {
4729        let txid = txid.to_ascii_lowercase();
4730        let outputs = self
4731            .storage
4732            .find_outputs_for_transaction(&txid, true)
4733            .await
4734            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4735        let mut report = RenotifyReport::default();
4736        for output in outputs.iter().filter(|o| o.topic == topic) {
4737            report.outputs += 1;
4738            report.vouts.push(output.output_index);
4739            // the body every WholeTx service receives NAMES the subject (BRC-95
4740            // atomic prefix) — the same rule as the live submit path
4741            let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4742                beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4743                    .ok()
4744                    .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4745                    .unwrap_or_else(|| beef.clone())
4746            });
4747            for (name, ls) in &self.lookup_services {
4748                let payload = match ls.admission_mode() {
4749                    AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4750                        txid: txid.clone(),
4751                        output_index: output.output_index,
4752                        topic: topic.to_string(),
4753                        satoshis: output.satoshis,
4754                        locking_script: output.output_script.clone(),
4755                        off_chain_values: None,
4756                    },
4757                    AdmissionMode::WholeTx => {
4758                        let Some(atomic_beef) = subject_named.as_ref() else {
4759                            report.faults.push(format!(
4760                                "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4761                                output.output_index
4762                            ));
4763                            continue;
4764                        };
4765                        OutputAdmittedByTopic::WholeTx {
4766                            atomic_beef: atomic_beef.clone(),
4767                            output_index: output.output_index,
4768                            topic: topic.to_string(),
4769                            off_chain_values: None,
4770                        }
4771                    }
4772                };
4773                match ls.output_admitted_by_topic(&payload).await {
4774                    Ok(()) => report.notified += 1,
4775                    Err(e) => report.faults.push(format!("{name}: {e}")),
4776                }
4777            }
4778        }
4779        Ok(report)
4780    }
4781
4782    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): FORGET a PHANTOM applied row —
4783    /// (txid, topic) recorded in `applied_transactions` while the topic holds
4784    /// NO output of that txid (the engine judged the tx and admitted nothing:
4785    /// the loop-2 JOIN `f9e85aab…` under the pre-D5 subject rule). Such a row
4786    /// does nothing but block a correct re-submit through the Phase-1 dedup
4787    /// (`/admin/readmit` on beta answered `deduped: ["tm_pot_beta"]` and the
4788    /// pot stayed `known:false`). A row with ANY stored output on the topic is
4789    /// a real admission and is never touched (returns false, as does a missing
4790    /// row). The forgotten bytes are then re-VALIDATED by the topic manager
4791    /// like any first submit — nothing is admitted here.
4792    pub async fn forget_phantom_applied(
4793        &self,
4794        txid: &str,
4795        topic: &str,
4796    ) -> Result<bool, EngineError> {
4797        let txid = txid.to_ascii_lowercase();
4798        let outputs = self
4799            .storage
4800            .find_outputs_for_transaction(&txid, false)
4801            .await
4802            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4803        if outputs.iter().any(|o| o.topic == topic) {
4804            return Ok(false);
4805        }
4806        let rec = AppliedTransaction {
4807            txid: txid.clone(),
4808            topic: topic.to_string(),
4809        };
4810        let exists = self
4811            .storage
4812            .does_applied_transaction_exist(&rec)
4813            .await
4814            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4815        if !exists {
4816            return Ok(false);
4817        }
4818        self.storage
4819            .delete_applied_transaction(&rec)
4820            .await
4821            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4822        Ok(true)
4823    }
4824
4825    pub async fn evict_output(
4826        &self,
4827        txid: &str,
4828        output_index: u32,
4829        topic: Option<&str>,
4830    ) -> Result<(), EngineError> {
4831        if let Some(topic) = topic {
4832            // Delete from storage
4833            self.storage
4834                .delete_output(txid, output_index, topic)
4835                .await
4836                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4837
4838            // Notify all lookup services
4839            for ls in self.lookup_services.values() {
4840                let _ = ls.output_evicted(txid, output_index).await;
4841            }
4842        } else {
4843            // No topic specified — find all outputs for this txid and evict matching ones
4844            let outputs = self
4845                .storage
4846                .find_outputs_for_transaction(txid, false)
4847                .await
4848                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4849
4850            for output in &outputs {
4851                if output.output_index == output_index {
4852                    self.storage
4853                        .delete_output(txid, output_index, &output.topic)
4854                        .await
4855                        .map_err(|e| EngineError::StorageError(e.to_string()))?;
4856                }
4857            }
4858
4859            // Notify all lookup services once
4860            for ls in self.lookup_services.values() {
4861                let _ = ls.output_evicted(txid, output_index).await;
4862            }
4863        }
4864
4865        Ok(())
4866    }
4867
4868    // ========================================================================
4869    // GASP sync
4870    // ========================================================================
4871
4872    /// Start GASP synchronization with peers for all configured topics.
4873    ///
4874    /// For each topic in `sync_configuration`:
4875    /// - `SyncTarget::Ship` — discovers peers via local `ls_ship` lookup, parses
4876    ///   SHIP advertisement scripts to extract domain URLs.
4877    /// - `SyncTarget::Peers(urls)` — uses the provided peer URLs directly.
4878    /// - `SyncTarget::Disabled` — skips the topic.
4879    ///
4880    /// Filters out our own `hosting_url` to avoid self-sync.
4881    ///
4882    /// Returns a `GASPSyncResult` summarizing discovered peers per topic.
4883    ///
4884    /// Discovers peers for each configured topic and runs GASP sync with each.
4885    ///
4886    /// When a `GASPRemoteFactory` is set (via `set_gasp_remote_factory`), this
4887    /// method creates `OverlayGASPStorage` + `GASPRemote` instances per
4888    /// (topic, peer) pair and runs `GASPSync::sync()` to exchange UTXOs.
4889    ///
4890    /// Without a factory, peer discovery still runs but no actual sync occurs
4891    /// (backwards-compatible with the previous discovery-only behavior).
4892    pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
4893        use crate::gasp::{GASPSync, DEFAULT_GASP_SYNC_LIMIT};
4894        use crate::gasp_overlay::OverlayGASPStorage;
4895
4896        if self.config.sync_configuration.is_empty() {
4897            info!("[GASP SYNC] No sync configuration — nothing to sync");
4898            return Ok(GASPSyncResult {
4899                topics_synced: HashMap::new(),
4900            });
4901        }
4902
4903        let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
4904
4905        for (topic, target) in sync_order(&self.config.sync_configuration) {
4906            // A CONFIGURED topic's peers are reserved part of a storage's
4907            // deferred-graph ceiling (bsv-low #555, the delta-2 fold's D2-M2).
4908            let configured_peers = matches!(target, SyncTarget::Peers(_));
4909            let (peers, sync_type) = match target {
4910                SyncTarget::Disabled => {
4911                    info!("[GASP SYNC] Topic {topic} is disabled — skipping");
4912                    continue;
4913                }
4914                SyncTarget::Ship => {
4915                    info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
4916                    let peers = self.discover_ship_peers(topic).await;
4917                    info!(
4918                        "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
4919                        peers.len()
4920                    );
4921                    (peers, "ship".to_string())
4922                }
4923                SyncTarget::Peers(peer_urls) => {
4924                    info!(
4925                        "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
4926                        peer_urls.len()
4927                    );
4928                    let peers: Vec<String> = peer_urls
4929                        .iter()
4930                        .filter(|url| {
4931                            if let Some(ref our_url) = self.config.hosting_url {
4932                                url.trim_end_matches('/') != our_url.trim_end_matches('/')
4933                            } else {
4934                                true
4935                            }
4936                        })
4937                        .cloned()
4938                        .collect();
4939
4940                    info!(
4941                        "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
4942                        peers.len()
4943                    );
4944                    (peers, "peers".to_string())
4945                }
4946            };
4947
4948            let mut errors = Vec::new();
4949            let mut pruned_inputs: u64 = 0;
4950            let mut discarded_graphs: u64 = 0;
4951            let mut anchor_walk_held_graphs: u64 = 0;
4952            let mut finalized_graphs: u64 = 0;
4953            let mut deadline_dropped_graphs: u64 = 0;
4954            let mut cursor_moves: Vec<CursorMove> = Vec::new();
4955            let mut deferred_graphs: u64 = 0;
4956            let mut resumed_graphs: u64 = 0;
4957            let mut converged_graphs: u64 = 0;
4958            let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
4959            let mut stalled_graphs: u64 = 0;
4960            let mut held_back_graphs: u64 = 0;
4961
4962            // If we have a remote factory, actually run GASP sync
4963            if let Some(ref factory) = self.gasp_remote_factory {
4964                for peer_url in &peers {
4965                    // bsv-low#302 quarantine gate: a peer at
4966                    // PEER_QUARANTINE_THRESHOLD consecutive failures is
4967                    // SKIPPED (no attempt recorded — its last-attempt age
4968                    // keeps growing) until the re-probe window opens. A
4969                    // health-read FAULT treats the peer as healthy: broken
4970                    // bookkeeping must never silence a live peer.
4971                    //
4972                    // The peer's health (its quarantine, its yieldless
4973                    // streak) is keyed on its NORMALIZED ORIGIN (bsv-low
4974                    // #555, the delta-2 fold's D2-M2): keyed on the URL, one
4975                    // server was a fresh peer for every spelling it
4976                    // advertised.
4977                    let health_key = crate::gasp::peer_origin(peer_url);
4978                    let health = self
4979                        .storage
4980                        .get_peer_sync_health(&health_key, topic)
4981                        .await
4982                        .unwrap_or_default();
4983                    if crate::gasp::peer_sync_quarantined(&health) {
4984                        warn!(
4985                            "[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)",
4986                            health.consecutive_failures,
4987                            health.secs_since_last_attempt,
4988                            crate::gasp::PEER_QUARANTINE_REPROBE_SECS
4989                        );
4990                        continue;
4991                    }
4992
4993                    info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
4994
4995                    // Get last interaction score for this (peer, topic) pair
4996                    let last_interaction = self
4997                        .storage
4998                        .get_last_interaction(peer_url, topic)
4999                        .await
5000                        .unwrap_or(0);
5001
5002                    // Create shared sink for finalized graphs
5003                    let sink = crate::gasp_overlay::new_finalized_graph_sink();
5004
5005                    // OPT-IN ancestor hydration: only when a fetcher is
5006                    // configured do we enable chain-backed ancestor fallback +
5007                    // strict-BEEF finalize. Default (None) = byte-identical to
5008                    // today (tolerant BEEF, peer errors abandon the graph;
5009                    // the one exception is the D8 prune of a manager-named
5010                    // input the peer answers it does not hold, which exists
5011                    // only WITHOUT a fetcher, see
5012                    // `GASPSync::process_incoming_node`).
5013                    let hydration_on = self.ancestor_fetcher.is_some();
5014
5015                    // Create storage adapter and remote
5016                    let mut gasp_storage =
5017                        OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5018                            .with_peer(peer_url.as_str())
5019                            .with_configured_peer(configured_peers)
5020                            .with_strict_beef(hydration_on)
5021                            .with_script_verification(self.verify_scripts)
5022                            .with_walk_budget(self.walk_budget);
5023                    if let Some(manager) = self.managers.get(topic) {
5024                        gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5025                    }
5026                    // bsv-low #551: the anchor check verifies each graph's
5027                    // root against THIS engine's tracker, with this engine's
5028                    // script switch, before finalize. No tracker is the
5029                    // reference's 'scripts only'.
5030                    if let Some(tracker) = self.chain_tracker.as_deref() {
5031                        gasp_storage = gasp_storage.with_chain_tracker(tracker);
5032                    }
5033                    let gasp_remote = factory.create_remote(peer_url, topic);
5034
5035                    let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5036                    // Graphs of this peer handed to submit (bsv-low #552).
5037                    let peer_finalized = std::cell::Cell::new(0u64);
5038                    let mut sync = GASPSync::new(
5039                        Box::new(gasp_storage),
5040                        gasp_remote,
5041                        last_interaction,
5042                        &log_prefix,
5043                        true, // unidirectional — overlay GASP is pull-only (submitNode throws); matches TS Engine.startGASPSync
5044                    )
5045                    .with_ancestor_fetcher(self.ancestor_fetcher.clone());
5046                    // bsv-low #555: a graph past its own budget is deferred,
5047                    // not dropped with the sync.
5048                    if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5049                        let (sleep, budget_ms) = (sleep.clone(), *budget_ms);
5050                        let half_sleep = sleep.clone();
5051                        sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5052                            max_calls: *max_calls,
5053                            max_bytes_fetched: self.graph_budget_limbs.0,
5054                            max_nodes: self.graph_budget_limbs.1,
5055                            deadline: Box::new(move || sleep(budget_ms)),
5056                            half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5057                        });
5058                    }
5059                    // bsv-low #552: under a budget each graph is submitted as
5060                    // it finalizes, so the deadline cannot take it back. With
5061                    // no budget nothing can drop the sync, and the graphs are
5062                    // submitted after it as they always were.
5063                    if self.peer_sync_budget.is_some() {
5064                        sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5065                            engine: self,
5066                            sink: sink.clone(),
5067                            peer_url,
5068                            topic,
5069                            submitted: &peer_finalized,
5070                        }));
5071                    }
5072
5073                    // bsv-low#302: bound THIS peer's sync to the configured
5074                    // budget. `None` from the race = the budget won and the
5075                    // sync future was dropped mid-flight. What it finalized
5076                    // before that is already submitted (the hook above); the
5077                    // cursor then advances to `completed_cursor` only, see
5078                    // the deadline arm below. The race is GUARDED: it never
5079                    // drops the sync inside one transaction's finalize
5080                    // submit (the lens fold's HIGH-1), it waits for that
5081                    // transaction and the hook stops at the boundary.
5082                    let sync_outcome = match &self.peer_sync_budget {
5083                        Some((sleep, budget_ms)) => {
5084                            crate::gasp::race_or_deadline_guarded(
5085                                sync.sync(Some(DEFAULT_GASP_SYNC_LIMIT)),
5086                                sleep(*budget_ms),
5087                                &self.finalize_gate,
5088                            )
5089                            .await
5090                        }
5091                        None => Some(sync.sync(Some(DEFAULT_GASP_SYNC_LIMIT)).await),
5092                    };
5093
5094                    let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5095                    // D8 decoy rule: branches pruned in this peer's walk. Not
5096                    // an error and not a failed UTXO, so it is counted apart
5097                    // from `errors` and never touches the outcome or cursor.
5098                    // Summed over peers: one decoy seen through two peers
5099                    // counts twice.
5100                    pruned_inputs += sync.pruned_inputs();
5101                    // bsv-low #551: graphs the anchor check refused. Counted
5102                    // apart from `errors` for the same reason as a prune: a
5103                    // refused graph is not a failed sync (the reference
5104                    // discards it and carries on).
5105                    discarded_graphs += sync.discarded_graphs();
5106                    anchor_walk_held_graphs += sync.anchor_walk_held();
5107                    // Whether the sync ran (to its end, or to the deadline)
5108                    // and whether it moved the cursor (bsv-low #555, the
5109                    // delta fold's D-M2).
5110                    let ran = !matches!(sync_outcome, Some(Err(_)));
5111                    let mut advanced_cursor = false;
5112                    match sync_outcome {
5113                        None => {
5114                            let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5115                            let msg = format!(
5116                                "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5117                            );
5118                            warn!("[GASP SYNC] {msg}");
5119                            errors.push(msg);
5120
5121                            // bsv-low #552: the work COMPLETED before the
5122                            // deadline is kept. Its graphs are already
5123                            // submitted (the hook); the cursor moves past the
5124                            // UTXOs that were completed and stops below the
5125                            // one in flight, whose graph is lost whole and
5126                            // walked again next tick. A graph the deadline
5127                            // met while it was being SUBMITTED is not "in
5128                            // flight" here: it left a prefix of whole
5129                            // transactions, is counted finalized, and its
5130                            // UTXO is below the cursor too.
5131                            let in_flight = u64::from(sync.graph_in_flight());
5132                            deadline_dropped_graphs += in_flight;
5133                            // bsv-low #555: the walk in hand is saved, so the
5134                            // next tick resumes it instead of walking it again.
5135                            sync.defer_in_flight().await;
5136                            let completed = sync.completed_cursor();
5137                            if completed > last_interaction {
5138                                match self
5139                                    .storage
5140                                    .update_last_interaction(peer_url, topic, completed)
5141                                    .await
5142                                {
5143                                    Ok(()) => cursor_moves.push(CursorMove {
5144                                        peer: peer_url.clone(),
5145                                        from: last_interaction,
5146                                        to: completed,
5147                                    }),
5148                                    Err(e) => warn!(
5149                                        "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5150                                    ),
5151                                }
5152                            }
5153                            // A tick that finalized a graph or moved the
5154                            // cursor reached a live peer: not a failed
5155                            // attempt for the quarantine count. So did one
5156                            // whose walk PROGRESSED (bsv-low #555: it
5157                            // appended a node, or completed its graph). A
5158                            // deferral that fetched nothing is no progress
5159                            // (the lens fold's H1: a hung peer deferred with
5160                            // 0 nodes on every tick and was never
5161                            // quarantined).
5162                            advanced_cursor = completed > last_interaction;
5163                            outcome_success = peer_finalized.get() > 0
5164                                || completed > last_interaction
5165                                || sync.deferral_stats().progressed > 0;
5166                            warn!(
5167                                "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5168                                peer_finalized.get(),
5169                                completed.max(last_interaction)
5170                            );
5171                        }
5172                        Some(Ok(())) => {
5173                            // Submit finalized graphs to the Engine (under a
5174                            // budget the hook already did, graph by graph,
5175                            // and the sink is empty here).
5176                            let landed = self
5177                                .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5178                                .await;
5179                            // bsv-low #555, the lens fold's H1: a sync that
5180                            // ran to its end with walks the per-graph budget
5181                            // cut having FETCHED NOTHING, and nothing else
5182                            // got done (no walk progressed, no graph
5183                            // finalized, the cursor did not move), reached
5184                            // a peer that does not answer: a FAILED attempt.
5185                            // Before #555 such a peer held the sync to the
5186                            // per-peer deadline, a failure; the per-graph
5187                            // budget had made it an `Ok` sync. A sync whose
5188                            // UTXOs failed in any other way is unchanged.
5189                            let stats = sync.deferral_stats();
5190                            if stats.stalled > 0
5191                                && stats.progressed == 0
5192                                && peer_finalized.get() == 0
5193                                && !(landed && sync.last_interaction > last_interaction)
5194                            {
5195                                outcome_success = false;
5196                                let msg = format!(
5197                                    "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5198                                    stats.stalled
5199                                );
5200                                warn!("[GASP SYNC] {msg}");
5201                                errors.push(msg);
5202                            }
5203
5204                            // Advance the persisted cursor when it moved forward — i.e.
5205                            // the peer reported UTXOs at a higher score than our last
5206                            // `since` (the in-memory cursor only grows on SEEN scores,
5207                            // so `>` already excludes the empty-bootstrap case where no
5208                            // UTXOs were seen). Mirrors TS `if (gasp.lastInteraction >
5209                            // lastInteraction)` — NOT gated on a new submission: a sync
5210                            // that re-sees already-known UTXOs must still advance, else
5211                            // every cron re-scans the same range forever (the cursor
5212                            // stayed pinned at 0 in prod).
5213                            //
5214                            // NOT advanced when a finalize submit above did
5215                            // not land (the lens fold's MEDIUM-1): the sink
5216                            // does not say which UTXO that graph was, so the
5217                            // whole cursor stays and the next sync is served
5218                            // the range again (what did land is known and
5219                            // skipped). Under a budget the hook fails that
5220                            // one UTXO instead and the gap guard has already
5221                            // capped `sync.last_interaction` below it.
5222                            advanced_cursor = landed && sync.last_interaction > last_interaction;
5223                            if !landed {
5224                                warn!(
5225                                    "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5226                                );
5227                            } else if sync.last_interaction > last_interaction {
5228                                match self
5229                                    .storage
5230                                    .update_last_interaction(peer_url, topic, sync.last_interaction)
5231                                    .await
5232                                {
5233                                    Ok(()) => cursor_moves.push(CursorMove {
5234                                        peer: peer_url.clone(),
5235                                        from: last_interaction,
5236                                        to: sync.last_interaction,
5237                                    }),
5238                                    Err(e) => warn!(
5239                                        "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5240                                    ),
5241                                }
5242                            }
5243                            info!(
5244                                "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5245                                sync.last_interaction
5246                            );
5247                        }
5248                        Some(Err(e)) => {
5249                            let msg = format!("{peer_url}: {e}");
5250                            warn!("[GASP SYNC] Sync failed: {msg}");
5251                            errors.push(msg);
5252                        }
5253                    }
5254                    // bsv-low #555, the delta fold's D-M2: "progress" (a node
5255                    // appended) is the peer's word. A sync the peer served
5256                    // work that finalized no graph and moved no cursor is
5257                    // YIELDLESS; past PEER_YIELDLESS_SYNCS_ALLOWED of them in
5258                    // a row each is a FAILED attempt, so a peer that serves
5259                    // one fresh root a tick and hangs on its input is
5260                    // quarantined. A quiet sync (nothing served) leaves the
5261                    // count; a fault of the count is fail-safe (no failure).
5262                    // Only with a per-graph budget: without one nothing is
5263                    // deferred and #302's rule is unchanged.
5264                    if ran && self.graph_budget.is_some() {
5265                        let yielded = peer_finalized.get() > 0 || advanced_cursor;
5266                        if yielded || sync.graphs_attempted() > 0 {
5267                            match self
5268                                .storage
5269                                .record_peer_sync_yield(&health_key, topic, yielded)
5270                                .await
5271                            {
5272                                Ok(streak)
5273                                    if !yielded
5274                                        && crate::gasp::yieldless_sync_failed(&streak)
5275                                        && outcome_success =>
5276                                {
5277                                    outcome_success = false;
5278                                    let msg = format!(
5279                                        "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5280                                        streak.yieldless_syncs,
5281                                        streak.secs_since_first.unwrap_or(0),
5282                                        crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5283                                        crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5284                                    );
5285                                    warn!("[GASP SYNC] {msg}");
5286                                    errors.push(msg);
5287                                }
5288                                Ok(_) => {}
5289                                Err(e) => warn!(
5290                                    "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5291                                ),
5292                            }
5293                        }
5294                    }
5295                    finalized_graphs += peer_finalized.get();
5296                    let deferral = sync.deferral_stats();
5297                    deferred_graphs += deferral.deferred;
5298                    resumed_graphs += deferral.resumed;
5299                    converged_graphs += deferral.converged;
5300                    stalled_graphs += deferral.stalled;
5301                    held_back_graphs += deferral.held_back;
5302                    dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5303                        peer: peer_url.clone(),
5304                        outpoint: d.outpoint,
5305                        reason: d.reason.as_str().to_string(),
5306                    }));
5307
5308                    // bsv-low#302: record the attempt outcome (success resets
5309                    // the consecutive-failure count; timeout/error increments
5310                    // it). Best-effort — a bookkeeping fault must never fail
5311                    // the sync pass itself.
5312                    //
5313                    // bsv-low #555, the delta-4 fold's D4-L2: with a
5314                    // per-graph budget, a peer that was at the quarantine
5315                    // threshold when this sync began is re-admitted only by
5316                    // a YIELD (a graph finalized, the cursor moved). A sync
5317                    // that did not fail and yielded nothing (an empty
5318                    // listing at its probe, a walk that only progressed) is
5319                    // NOT RECORDED: the failures stay, and so does the
5320                    // last-attempt stamp, so the peer is attended again on
5321                    // the next tick and its next failure re-arms the
5322                    // quarantine at once. Recorded as a success, a hostile
5323                    // peer's empty listing at each probe reset its failures
5324                    // and (the decay being for a peer that is not failed)
5325                    // its yieldless streak: 100 of 200 ticks. A health read
5326                    // that faulted read 0 failures: the old rule, fail-safe.
5327                    if outcome_success
5328                        && !(peer_finalized.get() > 0 || advanced_cursor)
5329                        && self.graph_budget.is_some()
5330                        && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5331                    {
5332                        info!(
5333                            "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5334                            health.consecutive_failures
5335                        );
5336                    } else if let Err(e) = self
5337                        .storage
5338                        .record_peer_sync_outcome(&health_key, topic, outcome_success)
5339                        .await
5340                    {
5341                        warn!(
5342                            "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5343                        );
5344                    }
5345                }
5346            }
5347
5348            topics_synced.insert(
5349                topic.clone(),
5350                TopicSyncResult {
5351                    peers,
5352                    sync_type,
5353                    errors,
5354                    pruned_inputs,
5355                    discarded_graphs,
5356                    anchor_walk_held_graphs,
5357                    finalized_graphs,
5358                    deadline_dropped_graphs,
5359                    cursor_moves,
5360                    deferred_graphs,
5361                    resumed_graphs,
5362                    converged_graphs,
5363                    dropped_graphs,
5364                    stalled_graphs,
5365                    held_back_graphs,
5366                },
5367            );
5368        }
5369
5370        info!(
5371            "[GASP SYNC] Peer discovery complete for {} topic(s)",
5372            topics_synced.len()
5373        );
5374
5375        Ok(GASPSyncResult { topics_synced })
5376    }
5377
5378    /// Drain `sink` and submit every finalized graph in it, ancestors first
5379    /// (`historical-tx-no-spv`: the anchor check already verified them).
5380    /// Each graph is added to `submitted` as its first submit starts. Returns
5381    /// whether EVERY transaction of every graph landed.
5382    ///
5383    /// Each graph here passed the anchor check: its root verified and the
5384    /// replay admitted it, so these submits rest on a checked premise and are
5385    /// not expected to fail. If one does not LAND, the REST OF THAT GRAPH is
5386    /// not submitted: the reference's `finalizeGraph` awaits each submit in
5387    /// order and its throw ends the loop (the BEEFs already submitted stay,
5388    /// it has no rollback either). A later BEEF would otherwise be judged
5389    /// without the coin its ancestor failed to leave, admit nothing and be
5390    /// recorded as applied, a dupe for good. "Landed" is read from the
5391    /// durability report, not from `Ok`: `submit` answers `Ok` when a storage
5392    /// write faulted and when the topic manager failed, and in both it
5393    /// records no applied row. A transaction landed when its topic is applied
5394    /// or was already applied (a dupe). Other graphs carry on, and the caller
5395    /// keeps the cursor from passing a graph that did not land.
5396    ///
5397    /// Each transaction's submit is one write section of the engine's gate
5398    /// ([`Engine::finalize_submit_gate`]) and the gate is asked between two:
5399    /// a deadline that is due stops the sequence there (this future is
5400    /// dropped at that await, between two whole transactions).
5401    async fn submit_finalized_graphs(
5402        &self,
5403        sink: &crate::gasp_overlay::FinalizedGraphSink,
5404        peer_url: &str,
5405        topic: &str,
5406        submitted: &std::cell::Cell<u64>,
5407    ) -> bool {
5408        let finalized: Vec<_> = sink
5409            .lock()
5410            .unwrap_or_else(std::sync::PoisonError::into_inner)
5411            .drain(..)
5412            .collect();
5413
5414        let mut all_landed = true;
5415        for graph in &finalized {
5416            for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5417                self.finalize_gate.stop_if_due().await;
5418                if position == 0 {
5419                    submitted.set(submitted.get() + 1);
5420                }
5421                let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5422                // No deadline drops a write section, and the section does
5423                // not drop ITSELF either (bsv-low #559, the delta lens's
5424                // DELTA-3 and the lens fold of 2026-10-07, F2,
5425                // `set_finalize_submit_budget`): the submit always runs to
5426                // its end. What is bounded is each storage call and hook
5427                // inside it ([`CallBound`]): one that never answers is that
5428                // call's FAULT, the submit handles it as any fault (nothing
5429                // of the transaction held, or all of it), the transaction
5430                // did not land, its UTXO fails and the cursor stays.
5431                let outcome = {
5432                    let _writing = self.finalize_gate.write_section();
5433                    let bound = self
5434                        .finalize_submit_budget
5435                        .as_ref()
5436                        .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5437                    self.submit_bounded(
5438                        &tagged,
5439                        SubmitMode::HistoricalTxNoSpv,
5440                        bound.as_ref(),
5441                        true,
5442                        WalkBreachPolicy::NotNow,
5443                    )
5444                    .await
5445                };
5446                let not_landed = match outcome {
5447                    Ok((_, report)) => {
5448                        let landed = report.is_durable()
5449                            && (report.applied_topics.contains(&graph.topic)
5450                                || report.deduped_topics.contains(&graph.topic));
5451                        (!landed).then(|| {
5452                            if report.is_durable() {
5453                                "the topic manager failed on it".to_string()
5454                            } else {
5455                                format!("not durable: {}", report.summary())
5456                            }
5457                        })
5458                    }
5459                    Err(e) => Some(e.to_string()),
5460                };
5461                if let Some(why) = not_landed {
5462                    warn!(
5463                        "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
5464                        graph.topic
5465                    );
5466                    all_landed = false;
5467                    break;
5468                }
5469            }
5470        }
5471
5472        if !finalized.is_empty() {
5473            info!(
5474                "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
5475                finalized.len()
5476            );
5477        }
5478        all_landed
5479    }
5480
5481    /// Discover peer overlay nodes for a topic via SHIP lookup.
5482    ///
5483    /// Queries the local `ls_ship` lookup service for SHIP advertisement records
5484    /// matching the given topic, then parses each record's PushDrop locking script
5485    /// to extract the advertised domain URL. Filters out our own hosting URL.
5486    async fn discover_ship_peers(&self, topic: &str) -> Vec<String> {
5487        let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
5488            warn!("[GASP SYNC] No ls_ship lookup service registered — cannot discover peers");
5489            return Vec::new();
5490        };
5491
5492        let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
5493
5494        let refs = match ship_ls.lookup(&question).await {
5495            Ok(LookupResult::OutputList(refs)) => refs,
5496            Ok(LookupResult::Answer(_)) => {
5497                error!(
5498                    "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
5499                     LookupAnswer; expected OutputList. Skipping."
5500                );
5501                return Vec::new();
5502            }
5503            Err(e) => {
5504                error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
5505                return Vec::new();
5506            }
5507        };
5508
5509        let mut domains = HashSet::new();
5510        for reference in &refs {
5511            if let Ok(Some(output)) = self
5512                .storage
5513                .find_output(&reference.txid, reference.output_index, None, None, false)
5514                .await
5515            {
5516                if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
5517                    // Every spelling of our own origin is ourselves (the
5518                    // delta-2 fold's D2-M2: `https://us/?x` passed the
5519                    // string compare).
5520                    if let Some(ref our_url) = self.config.hosting_url {
5521                        if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
5522                            || crate::gasp::peer_origin(&domain)
5523                                == crate::gasp::peer_origin(our_url)
5524                        {
5525                            continue;
5526                        }
5527                    }
5528                    domains.insert(domain);
5529                }
5530            }
5531        }
5532
5533        // One peer per normalized origin (bsv-low #555, the delta-2 fold's
5534        // D2-M2, restored by the delta-4 fold's D4-M1): eight spellings of
5535        // one server are one peer, the one its health row is keyed by.
5536        crate::gasp::ship_peers_by_origin(domains)
5537    }
5538
5539    // ========================================================================
5540    // Helpers
5541    // ========================================================================
5542
5543    /// Get a reference to the storage backend.
5544    pub fn storage(&self) -> &dyn Storage {
5545        self.storage.as_ref()
5546    }
5547
5548    /// Get the engine configuration.
5549    pub fn config(&self) -> &EngineConfig {
5550        &self.config
5551    }
5552}
5553
5554/// The SPV check of [`Engine::submit`], as one function over a BORROWED
5555/// tracker: the reference's `tx.verify(chainTracker)` (roots against the
5556/// tracker, every unproven input's script executed, the value rule), its
5557/// `tx.verify('scripts only')` when `chain_tracker` is `None` (roots accepted
5558/// unchecked, scripts still run), and the pre-2026-09-08 structural check when
5559/// `verify_scripts` is `false` ([`Engine::set_script_verification`]'s escape
5560/// hatch).
5561///
5562/// There is ONE verifier. `Engine::submit` calls this with its own tracker,
5563/// switch and walk budget; the GASP anchor check
5564/// (`OverlayGASPStorage::validate_graph_anchor`, bsv-low #551) calls it with
5565/// the same three, handed over by `Engine::start_gasp_sync`.
5566///
5567/// `Ok(Some(_))` is a walk that COULD NOT RUN within `budget` (bsv-low #592):
5568/// not a verdict on the transaction. The caller goes on as it would without a
5569/// walk and says so.
5570pub(crate) async fn verify_spv_like_the_reference(
5571    chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5572    verify_scripts: bool,
5573    beef_bytes: &[u8],
5574    subject_txid: &str,
5575    budget: DoorBudget,
5576) -> Result<Option<WalkCouldNotRun>, EngineError> {
5577    verify_spv_trusting(
5578        chain_tracker,
5579        verify_scripts,
5580        beef_bytes,
5581        subject_txid,
5582        &HashSet::new(),
5583        budget,
5584    )
5585    .await
5586}
5587
5588/// [`verify_spv_like_the_reference`] trusting the transactions an earlier
5589/// walk of the same submit passed over (a predecessor the door lands first,
5590/// the E1D delta fold, L4): the script walk neither checks nor descends
5591/// them. The structural escape hatch checks the whole BEEF it is given.
5592async fn verify_spv_trusting(
5593    chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5594    verify_scripts: bool,
5595    beef_bytes: &[u8],
5596    subject_txid: &str,
5597    trusted: &HashSet<String>,
5598    budget: DoorBudget,
5599) -> Result<Option<WalkCouldNotRun>, EngineError> {
5600    if verify_scripts {
5601        Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
5602    } else {
5603        Engine::verify_spv_structurally(chain_tracker, beef_bytes)
5604            .await
5605            .map(|()| None)
5606    }
5607}
5608
5609/// The BUMP that proves each txid of `beef`: the first BUMP whose level 0
5610/// carries it, as `Beef::find_bump` picks, built in one pass over the BUMPs'
5611/// leaves (NL-6e; `find_bump` per transaction scans them all each time).
5612fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
5613    let mut proven: HashMap<&str, usize> = HashMap::new();
5614    for (at, bump) in beef.bumps.iter().enumerate() {
5615        for leaf in bump.path.first().into_iter().flatten() {
5616            if let Some(hash) = leaf.hash.as_deref() {
5617                proven.entry(hash).or_insert(at);
5618            }
5619        }
5620    }
5621    proven
5622}
5623
5624/// The transactions the SPV walk of `from` passes over in `beef`
5625/// ([`Engine::verify_beef_linear_with`]'s traversal, nothing checked): `from`
5626/// and every source the BEEF carries, a proven transaction included and not
5627/// descended. The E1D delta fold, L4: what a landing need not walk again.
5628fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
5629    let bodies: HashMap<String, &Transaction> = beef
5630        .txs
5631        .iter()
5632        .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
5633        .collect();
5634    let proven = proven_index(beef);
5635    let mut cover = HashSet::new();
5636    let mut queue = vec![from.to_string()];
5637    while let Some(txid) = queue.pop() {
5638        if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
5639            continue;
5640        }
5641        if let Some(tx) = bodies.get(&txid) {
5642            queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
5643        }
5644    }
5645    cover
5646}
5647
5648/// The engine's [`crate::gasp::FinalizedGraphHook`] (bsv-low #552): submit
5649/// what a peer's sync just finalized, before its next UTXO is asked for.
5650struct SubmitAsFinalized<'e> {
5651    engine: &'e Engine,
5652    sink: crate::gasp_overlay::FinalizedGraphSink,
5653    peer_url: &'e str,
5654    topic: &'e str,
5655    submitted: &'e std::cell::Cell<u64>,
5656}
5657
5658#[async_trait::async_trait(?Send)]
5659impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
5660    async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
5661        // A graph is counted as its first submit starts, and the deadline
5662        // cannot land inside a submit: a sync dropped in here has written at
5663        // least that transaction whole (an ancestors-first prefix).
5664        let landed = self
5665            .engine
5666            .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
5667            .await;
5668        if landed {
5669            Ok(())
5670        } else {
5671            // Fails this UTXO: the cursor stays below it and the next sync
5672            // asks for it again (the lens fold's MEDIUM-1).
5673            Err(crate::gasp::GASPError::StorageError(
5674                "a finalize submit did not land".to_string(),
5675            ))
5676        }
5677    }
5678}
5679
5680/// Result of a GASP sync operation.
5681///
5682/// Returned by `Engine::start_gasp_sync()` to summarize what happened.
5683#[derive(Debug, Clone, Serialize, Deserialize)]
5684pub struct GASPSyncResult {
5685    /// Per-topic sync results.
5686    pub topics_synced: HashMap<String, TopicSyncResult>,
5687}
5688
5689/// Sync result for a single topic.
5690#[derive(Debug, Clone, Serialize, Deserialize)]
5691pub struct TopicSyncResult {
5692    /// Peer URLs discovered or configured for this topic.
5693    pub peers: Vec<String>,
5694    /// How peers were determined: "ship" or "peers".
5695    pub sync_type: String,
5696    /// Any errors encountered during sync (peer URL -> error message): a
5697    /// peer whose sync failed or ran over its budget. A single UTXO whose
5698    /// graph ingest failed is NOT listed here: it is warned (`Error ingesting
5699    /// UTXO`), held back by the cursor gap guard and asked for again by the
5700    /// next sync.
5701    pub errors: Vec<String>,
5702    /// Manager-named inputs pruned because a peer answered that it does not
5703    /// hold them (the D8 decoy rule, `GASPSync::pruned_inputs`). Counted per
5704    /// distinct outpoint PER PEER per sync and summed over this topic's
5705    /// peers: one decoy seen through two peers reads 2. Not errors: the
5706    /// graphs completed without those branches.
5707    #[serde(default)]
5708    pub pruned_inputs: u64,
5709    /// Graphs the anchor check REFUSED and discarded whole (bsv-low #551,
5710    /// `GASPSync::discarded_graphs`): a root whose BEEF does not verify, or
5711    /// one the topic manager does not admit at the end of the replay. Summed
5712    /// over this topic's peers. Not errors, and nothing of them was admitted.
5713    #[serde(default)]
5714    pub discarded_graphs: u64,
5715    /// Graphs HELD because their anchor's script walk could not run within
5716    /// the engine's walk budget (bsv-low #592; the E592 lens fold, H1 and
5717    /// L3; `GASPSync::anchor_walk_held`), summed over this topic's peers.
5718    /// Nothing of them was finalized; each UTXO stays below its cursor until
5719    /// the peer serves its root proven. Not errors and not discarded.
5720    #[serde(default)]
5721    pub anchor_walk_held_graphs: u64,
5722    /// Graphs that passed the anchor check and were handed to submit, summed
5723    /// over this topic's peers (bsv-low #552). Under a per-peer budget a
5724    /// graph is counted, and submitted, the moment it finalizes, so the
5725    /// count includes those of a peer the deadline then dropped. A counted
5726    /// graph had its first transaction submitted whole (the deadline never
5727    /// lands inside a submit); one whose later submit did not land is still
5728    /// counted, and its UTXO stays below the cursor.
5729    /// A graph is counted as its first submit STARTS; a first submit that FAULTS
5730    /// still counts here, so this is not a delivered count (bsv-low #559).
5731    #[serde(default)]
5732    pub finalized_graphs: u64,
5733    /// Graphs that were mid-walk when a peer's sync was dropped at its
5734    /// budget (bsv-low #552): at most one per peer per sync. NOTHING of such
5735    /// a graph was admitted; the next sync walks it again. The same root
5736    /// counted tick after tick with no `finalized_graphs` and no
5737    /// `cursor_moves` is ONE graph whose walk outlasts the budget (with a
5738    /// per-graph budget it is also counted in `deferred_graphs` and resumed,
5739    /// bsv-low #555).
5740    #[serde(default)]
5741    pub deadline_dropped_graphs: u64,
5742    /// Every persisted cursor that moved in this sync (bsv-low #552), one
5743    /// entry per peer. After a completed sync `to` is the peer's highest
5744    /// score seen (less the gap guard); after a deadline it is
5745    /// `GASPSync::completed_cursor`: past completed UTXOs only.
5746    #[serde(default)]
5747    pub cursor_moves: Vec<CursorMove>,
5748    /// Graphs DEFERRED in this sync, summed over peers (bsv-low #555,
5749    /// `Engine::set_graph_budget`): a new record, or a resumed graph
5750    /// deferred again. Their UTXOs are held below the cursor.
5751    #[serde(default)]
5752    pub deferred_graphs: u64,
5753    /// Graphs RESUMED from a record in this sync.
5754    #[serde(default)]
5755    pub resumed_graphs: u64,
5756    /// Resumed graphs that completed and landed in this sync (their record
5757    /// deleted).
5758    #[serde(default)]
5759    pub converged_graphs: u64,
5760    /// Records deleted without converging, with their reason.
5761    #[serde(default)]
5762    pub dropped_graphs: Vec<DroppedDeferral>,
5763    /// Walks the per-graph budget (or the per-peer deadline) cut having
5764    /// fetched NOTHING in their pass (bsv-low #555, the lens fold's H1). A
5765    /// peer's sync whose only outcome was these is a failed attempt for the
5766    /// quarantine count.
5767    #[serde(default)]
5768    pub stalled_graphs: u64,
5769    /// Records NOT resumed in this sync: the resumes of a peer's pass share
5770    /// ONE per-graph budget and had spent it (the lens fold's M1). Held for
5771    /// the next sync, untouched.
5772    #[serde(default)]
5773    pub held_back_graphs: u64,
5774}
5775
5776/// A deferred graph's record deleted without converging (bsv-low #555,
5777/// [`TopicSyncResult::dropped_graphs`]).
5778#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5779pub struct DroppedDeferral {
5780    /// The peer URL.
5781    pub peer: String,
5782    /// The graph's root outpoint.
5783    pub outpoint: String,
5784    /// [`crate::gasp::DropReason::as_str`].
5785    pub reason: String,
5786}
5787
5788/// One peer's persisted `last_interaction` cursor moving `from` -> `to` in a
5789/// sync ([`TopicSyncResult::cursor_moves`]).
5790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5791pub struct CursorMove {
5792    /// The peer URL.
5793    pub peer: String,
5794    /// The cursor the sync entered with.
5795    pub from: u64,
5796    /// The cursor it persisted.
5797    pub to: u64,
5798}
5799
5800/// Get current time in milliseconds (for output scores).
5801///
5802/// Uses `js_sys::Date::now()` on wasm32 (Cloudflare Workers) since
5803/// `std::time::SystemTime` is not available on that platform.
5804fn current_timestamp_ms() -> f64 {
5805    #[cfg(target_arch = "wasm32")]
5806    {
5807        js_sys::Date::now()
5808    }
5809    #[cfg(not(target_arch = "wasm32"))]
5810    {
5811        std::time::SystemTime::now()
5812            .duration_since(std::time::UNIX_EPOCH)
5813            .map_or(0.0, |d| d.as_millis() as f64)
5814    }
5815}
5816
5817/// Extract the domain field from a SHIP PushDrop locking script.
5818///
5819/// SHIP PushDrop format: field[0] = "SHIP", field[1] = identity_key,
5820/// field[2] = domain (UTF-8), field[3] = topic.
5821/// Returns `None` if the script cannot be parsed or is not a SHIP advertisement.
5822fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
5823    use bsv_rs::script::templates::PushDrop;
5824
5825    let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
5826    let pushdrop = PushDrop::decode(&script.into()).ok()?;
5827
5828    if pushdrop.fields.len() < 3 {
5829        return None;
5830    }
5831
5832    let protocol = String::from_utf8_lossy(&pushdrop.fields[0]);
5833    if protocol != "SHIP" {
5834        return None;
5835    }
5836
5837    let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
5838    if domain.is_empty() {
5839        return None;
5840    }
5841
5842    Some(domain)
5843}
5844
5845// ============================================================================
5846// Submit-time spend verification helpers (reference parity, 2026-09-08)
5847// ============================================================================
5848
5849// ============================================================================
5850// Error type
5851// ============================================================================
5852
5853#[derive(Debug, thiserror::Error)]
5854pub enum EngineError {
5855    #[error("This server does not support this topic: {0}")]
5856    UnsupportedTopic(String),
5857
5858    #[error("Lookup service not found for provider: {0}")]
5859    LookupServiceNotFound(String),
5860
5861    /// GASP: `/requestForeignGASPNode` asked for a graph/txid we don't
5862    /// have locally. Matches mainline's 400-class "not found" response.
5863    #[error("No matching output found!")]
5864    NodeNotFound,
5865
5866    #[error("lookup failed: {0}")]
5867    LookupFailed(String),
5868
5869    /// The CALLER's query was malformed — a bad identity key, a gameId that is
5870    /// not 32 bytes of hex, a missing required field. Distinct from
5871    /// `LookupFailed`, which means WE failed: without the distinction every
5872    /// caller mistake was reported as a server fault (HTTP 500), so a malformed
5873    /// query and a genuine outage were indistinguishable in logs and alerts on
5874    /// an unauthenticated endpoint anyone can hit.
5875    #[error("invalid query: {0}")]
5876    InvalidQuery(String),
5877
5878    #[error("storage error: {0}")]
5879    StorageError(String),
5880
5881    #[error("broadcast error: {0}")]
5882    BroadcastError(String),
5883
5884    #[error("SPV verification failed: {0}")]
5885    SpvError(String),
5886
5887    #[error("BEEF parsing failed: {0}")]
5888    BeefParseError(String),
5889
5890    /// A spend in the submitted BEEF does not satisfy its source output's
5891    /// locking script: the input's unlocking script was EXECUTED (reference
5892    /// parity, 2026-09-08) and the interpreter refused it. Distinct from
5893    /// [`EngineError::SpvError`] (a bad or unverifiable PROOF, a missing
5894    /// source, a chain-tracker fault) so an operator can tell a bad spend from
5895    /// a bad proof. `input_index` is the failing input's position in the
5896    /// transaction whose script failed (the subject, or an unproven
5897    /// ancestor of it in the same BEEF); `reason` is the interpreter's own
5898    /// message.
5899    #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
5900    ScriptVerificationFailed {
5901        subject_txid: String,
5902        input_index: u32,
5903        reason: String,
5904    },
5905
5906    /// The DOOR walk ([`Engine::verify_scripts_only`]) hit a STRUCTURAL fault
5907    /// before reaching a verdict on `at_txid` (a source the BEEF lacks, a
5908    /// parse fault, the value rule): not the interpreter's verdict, so not a
5909    /// refusal for a caller whose bar is the network. `subject_judged` says
5910    /// whether the subject's own inputs had all executed before the fault
5911    /// (an ancestor faulted) or not (the subject itself is unjudged).
5912    #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
5913    ScriptWalkInconclusive {
5914        at_txid: String,
5915        subject_judged: bool,
5916        reason: String,
5917    },
5918
5919    /// The DOOR walk exceeded its own bound ([`DoorBudget`]: the static work
5920    /// estimate, the interpreter's memory limit tripping, or the estimate of
5921    /// the door's own memory; `limb` says which): the door's verdict, never
5922    /// the network's — the request proceeds to the network.
5923    #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
5924    ScriptWalkOverBudget {
5925        at_txid: String,
5926        subject_judged: bool,
5927        limb: DoorLimb,
5928        what: String,
5929    },
5930
5931    /// The submit's own script walk COULD NOT RUN within the engine's
5932    /// [`DoorBudget`] (bsv-low #592) and nothing on record stands in for it
5933    /// ([`WalkBreachPolicy::NotNow`], every door but the queue replay of a
5934    /// gated submission; the E592 lens fold, H1). NOT NOW: nothing was
5935    /// judged, nothing written, and it is NOT a refusal either: the budget
5936    /// says nothing about the transaction. The way out is the subject
5937    /// PROVEN: a proven transaction is not walked, so an honest body too
5938    /// big to walk unmined lands once its block is mined (a client retries
5939    /// after the block, not at once).
5940    #[error(
5941        "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
5942        .0.limb.as_str(),
5943        .0.what
5944    )]
5945    WalkCouldNotRun(WalkCouldNotRun),
5946
5947    #[error("{0}")]
5948    Other(String),
5949}
5950
5951impl From<StorageError> for EngineError {
5952    fn from(e: StorageError) -> Self {
5953        EngineError::StorageError(e.to_string())
5954    }
5955}
5956
5957impl From<TopicManagerError> for EngineError {
5958    fn from(e: TopicManagerError) -> Self {
5959        EngineError::Other(e.to_string())
5960    }
5961}
5962
5963impl From<LookupServiceError> for EngineError {
5964    fn from(e: LookupServiceError) -> Self {
5965        EngineError::LookupFailed(e.to_string())
5966    }
5967}
5968
5969impl From<AdvertiserError> for EngineError {
5970    fn from(e: AdvertiserError) -> Self {
5971        EngineError::Other(e.to_string())
5972    }
5973}
5974
5975// ============================================================================
5976// Tests
5977// ============================================================================
5978
5979/// The order `start_gasp_sync` takes its topics in (bsv-low #555, the delta
5980/// fold's D-M1): topics with CONFIGURED peers (`SyncTarget::Peers`) first,
5981/// then those whose peers `ls_ship` discovers (anyone may advertise a host
5982/// there), each by name. The deferred-graph records share one storage
5983/// ceiling; a place it frees goes to a configured peer before a discovered
5984/// one, and a pass the outer deadline cuts has reached the configured peers.
5985/// The map's own order was arbitrary.
5986pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
5987    let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
5988    order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
5989    order
5990}
5991
5992#[cfg(test)]
5993mod tests {
5994    use super::*;
5995    use crate::lookup_service::LookupService as LookupServiceTrait;
5996    use crate::storage::memory::MemoryStorage;
5997    use crate::topic_manager::TopicManager as TopicManagerTrait;
5998    use async_trait::async_trait;
5999    use std::sync::Mutex;
6000
6001    // bsv-low #555, the delta fold's D-M1: the configured peers' topics are
6002    // synced before the ls_ship-discovered ones, each class by name; the map's
6003    // order was arbitrary.
6004    #[test]
6005    fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6006        let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6007        let config: SyncConfiguration = HashMap::from([
6008            ("tm_a".to_string(), SyncTarget::Ship),
6009            ("tm_z".to_string(), peers("https://z")),
6010            ("tm_b".to_string(), SyncTarget::Ship),
6011            ("tm_m".to_string(), peers("https://m")),
6012            ("tm_off".to_string(), SyncTarget::Disabled),
6013        ]);
6014        let order: Vec<&str> = sync_order(&config)
6015            .iter()
6016            .map(|(t, _)| t.as_str())
6017            .collect();
6018        assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6019    }
6020
6021    // ── Mock TopicManager ──────────────────────────────────────────────
6022
6023    struct MockTopicManager {
6024        admit_indices: Vec<u32>,
6025    }
6026
6027    impl MockTopicManager {
6028        fn admitting(indices: Vec<u32>) -> Self {
6029            Self {
6030                admit_indices: indices,
6031            }
6032        }
6033    }
6034
6035    #[async_trait(?Send)]
6036    impl TopicManagerTrait for MockTopicManager {
6037        async fn identify_admissible_outputs(
6038            &self,
6039            _tx: &Transaction,
6040            _previous_coins: &[u8],
6041            _off_chain_values: Option<&[u8]>,
6042            _mode: SubmitMode,
6043            _context: &TopicAdmittanceContext,
6044        ) -> Result<AdmittanceInstructions, TopicManagerError> {
6045            Ok(AdmittanceInstructions {
6046                outputs_to_admit: self.admit_indices.clone(),
6047                coins_to_retain: vec![],
6048                coins_removed: None,
6049            })
6050        }
6051
6052        async fn get_documentation(&self) -> String {
6053            "Mock topic manager".to_string()
6054        }
6055
6056        async fn get_metadata(&self) -> ServiceMetadata {
6057            ServiceMetadata {
6058                name: "mock-tm".to_string(),
6059                description: Some("Mock for testing".to_string()),
6060                ..Default::default()
6061            }
6062        }
6063    }
6064
6065    // ── Mock LookupService ─────────────────────────────────────────────
6066
6067    struct MockLookupService {
6068        records: Mutex<Vec<UTXOReference>>,
6069    }
6070
6071    impl MockLookupService {
6072        fn new() -> Self {
6073            Self {
6074                records: Mutex::new(Vec::new()),
6075            }
6076        }
6077    }
6078
6079    #[async_trait(?Send)]
6080    impl LookupServiceTrait for MockLookupService {
6081        fn admission_mode(&self) -> AdmissionMode {
6082            AdmissionMode::LockingScript
6083        }
6084
6085        fn spend_notification_mode(&self) -> SpendNotificationMode {
6086            SpendNotificationMode::None
6087        }
6088
6089        async fn output_admitted_by_topic(
6090            &self,
6091            payload: &OutputAdmittedByTopic,
6092        ) -> Result<(), LookupServiceError> {
6093            let (txid, oi) = match payload {
6094                OutputAdmittedByTopic::LockingScript {
6095                    txid, output_index, ..
6096                } => (txid.clone(), *output_index),
6097                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
6098                    ("whole".into(), *output_index)
6099                }
6100            };
6101            self.records.lock().unwrap().push(UTXOReference {
6102                txid,
6103                output_index: oi,
6104            });
6105            Ok(())
6106        }
6107
6108        async fn output_evicted(
6109            &self,
6110            txid: &str,
6111            output_index: u32,
6112        ) -> Result<(), LookupServiceError> {
6113            self.records
6114                .lock()
6115                .unwrap()
6116                .retain(|r| !(r.txid == txid && r.output_index == output_index));
6117            Ok(())
6118        }
6119
6120        async fn lookup(
6121            &self,
6122            _question: &LookupQuestion,
6123        ) -> Result<LookupResult, LookupServiceError> {
6124            Ok(LookupResult::OutputList(
6125                self.records.lock().unwrap().clone(),
6126            ))
6127        }
6128
6129        async fn get_documentation(&self) -> String {
6130            "Mock lookup service".to_string()
6131        }
6132
6133        async fn get_metadata(&self) -> ServiceMetadata {
6134            ServiceMetadata {
6135                name: "mock-ls".to_string(),
6136                ..Default::default()
6137            }
6138        }
6139    }
6140
6141    // ── Test BEEF data ──────────────────────────────────────────────────
6142
6143    /// Real BRC-62 BEEF from TS overlay-services test suite.
6144    const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
6145    const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
6146
6147    fn decode_hex(hex: &str) -> Vec<u8> {
6148        (0..hex.len())
6149            .step_by(2)
6150            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
6151            .collect()
6152    }
6153
6154    fn test_beef() -> Vec<u8> {
6155        decode_hex(TEST_BEEF_HEX)
6156    }
6157
6158    fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
6159        TaggedBEEF::new(
6160            test_beef(),
6161            topics.into_iter().map(str::to_string).collect(),
6162        )
6163    }
6164
6165    // ── Helpers ────────────────────────────────────────────────────────
6166
6167    fn make_engine(admit_indices: Vec<u32>) -> Engine {
6168        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6169        managers.insert(
6170            "tm_test".to_string(),
6171            Box::new(MockTopicManager::admitting(admit_indices)),
6172        );
6173
6174        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
6175        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
6176
6177        let storage = Box::new(MemoryStorage::new());
6178
6179        Engine::new(
6180            managers,
6181            lookup_services,
6182            storage,
6183            None,
6184            EngineConfig::default(),
6185        )
6186    }
6187
6188    // ── Tests ──────────────────────────────────────────────────────────
6189
6190    /// bsv-low#289 parse-once + zanaadu#284 subject discipline: the tx handed
6191    /// to every topic manager must be the ATOMIC SUBJECT of the submitted
6192    /// BEEF, not whatever sits last in wire order. bsv-rs < 0.3.20's
6193    /// `from_beef(_, None)` picked `txs.last()`; wallet serializers do not all
6194    /// place the subject last, so a TM could be handed a PARENT to validate.
6195    /// This pins the engine on the fixed rule (`txid ?? atomicTxid ?? last`).
6196    #[tokio::test]
6197    async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
6198        use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
6199        use std::cell::RefCell;
6200        use std::rc::Rc;
6201
6202        // Rebuild the fixture chain SUBJECT-FIRST via to_writer (to_binary
6203        // re-sorts parents-first and would hide the trigger).
6204        let parsed = Beef::from_binary(&test_beef()).unwrap();
6205        let parent = parsed.txs[0].tx().unwrap().clone();
6206        let child = parsed.txs[1].tx().unwrap().clone();
6207        let child_id = child.id();
6208        let parent_id = parent.id();
6209        let mut beef = Beef::new();
6210        // The parent's BUMP rides along: the reference-parity walk
6211        // (2026-09-08) executes the subject's script against the parent and
6212        // then trusts the parent on its proof; without the BUMP it would
6213        // demand the parent's own ancestry ("Input 0 has no source
6214        // transaction"), which this fixture does not carry.
6215        for bump in &parsed.bumps {
6216            beef.merge_bump(bump.clone());
6217        }
6218        beef.merge_transaction(child);
6219        beef.merge_transaction(parent);
6220        let mut w = bsv_rs::primitives::encoding::Writer::new();
6221        w.write_u32_le(ATOMIC_BEEF);
6222        let mut le = hex::decode(&child_id).unwrap();
6223        le.reverse();
6224        w.write_bytes(&le);
6225        beef.to_writer(&mut w);
6226        let atomic_wire_order = w.into_bytes();
6227
6228        // A TM that records the txid it is handed.
6229        struct RecordingTm(Rc<RefCell<Option<String>>>);
6230        #[async_trait(?Send)]
6231        impl TopicManagerTrait for RecordingTm {
6232            async fn identify_admissible_outputs(
6233                &self,
6234                tx: &Transaction,
6235                _previous_coins: &[u8],
6236                _off_chain_values: Option<&[u8]>,
6237                _mode: SubmitMode,
6238                _context: &TopicAdmittanceContext,
6239            ) -> Result<AdmittanceInstructions, TopicManagerError> {
6240                *self.0.borrow_mut() = Some(tx.id());
6241                Ok(AdmittanceInstructions {
6242                    outputs_to_admit: vec![0],
6243                    coins_to_retain: vec![],
6244                    coins_removed: None,
6245                })
6246            }
6247            async fn get_documentation(&self) -> String {
6248                String::new()
6249            }
6250            async fn get_metadata(&self) -> ServiceMetadata {
6251                ServiceMetadata {
6252                    name: "tm_test".to_string(),
6253                    ..Default::default()
6254                }
6255            }
6256        }
6257
6258        let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
6259        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6260        managers.insert(
6261            "tm_test".to_string(),
6262            Box::new(RecordingTm(Rc::clone(&seen))),
6263        );
6264        let engine = Engine::new(
6265            managers,
6266            HashMap::new(),
6267            Box::new(MemoryStorage::new()),
6268            None,
6269            EngineConfig::default(),
6270        );
6271
6272        let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
6273        let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
6274
6275        let handed = seen.borrow().clone().expect("TM was not invoked");
6276        assert_eq!(
6277            handed, child_id,
6278            "TM must be handed the atomic subject, got the wire-order pick"
6279        );
6280        assert_ne!(handed, parent_id);
6281    }
6282
6283    /// #4 REGRESSION: history hydration must never DOWNGRADE provenance.
6284    ///
6285    /// `get_utxo_history` used to `Transaction::from_beef(beef, None)` and then
6286    /// `tx.to_beef(true)`, which keeps only the subject and DROPS the stored
6287    /// BUMPs. Measured on prod before the fix: `ls_low` byGameId `73de6e48…`
6288    /// returned 1,172 B / 1 tx / 1 bump (PROVEN) with no header, and 676 B /
6289    /// 1 tx / 0 bumps with `x-history-depth: 3`. A caller asking for MORE
6290    /// provenance got a BEEF no wallet can verify — silently, behind a 200.
6291    ///
6292    /// There was NO test on this function at all, which is exactly how that
6293    /// shipped. This one asserts the invariant that matters: whatever else
6294    /// hydration does, a proof that went in comes back out.
6295    #[tokio::test]
6296    async fn history_hydration_preserves_the_stored_proof() {
6297        use bsv_rs::transaction::Beef;
6298
6299        let engine = make_engine(vec![0]);
6300        let stored = test_beef();
6301        let bumps_before = Beef::from_binary(&stored)
6302            .expect("fixture BEEF parses")
6303            .bumps
6304            .len();
6305        assert!(
6306            bumps_before > 0,
6307            "fixture must actually carry a proof, or this cell proves nothing"
6308        );
6309
6310        let output = Output {
6311            txid: TEST_TXID.to_string(),
6312            output_index: 0,
6313            output_script: vec![0x76, 0xa9],
6314            satoshis: 1000,
6315            topic: "tm_test".to_string(),
6316            spent: false,
6317            // No admitted parents: hydration has nothing to add, so anything
6318            // it REMOVES is pure loss — the cleanest form of the bug.
6319            outputs_consumed: vec![],
6320            consumed_by: vec![],
6321            beef: Some(stored.clone()),
6322            block_height: None,
6323            score: Some(1000.0),
6324        };
6325
6326        let hydrated = engine
6327            .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
6328            .await
6329            .expect("hydration must not error")
6330            .expect("hydration must return the output");
6331        let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
6332            .expect("hydrated BEEF parses");
6333
6334        assert_eq!(
6335            beef.bumps.len(),
6336            bumps_before,
6337            "asking for history must not strip the merkle proof (issue #4)"
6338        );
6339    }
6340
6341    #[tokio::test]
6342    async fn test_submit_unsupported_topic_errors() {
6343        let engine = make_engine(vec![0]);
6344        let beef = test_tagged_beef(vec!["tm_nonexistent"]);
6345
6346        let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
6347        assert!(result.is_err());
6348        assert!(matches!(
6349            result.unwrap_err(),
6350            EngineError::UnsupportedTopic(_)
6351        ));
6352    }
6353
6354    #[tokio::test]
6355    async fn test_submit_admits_outputs() {
6356        let engine = make_engine(vec![0, 1]);
6357        let beef = test_tagged_beef(vec!["tm_test"]);
6358
6359        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6360
6361        // STEAK should have tm_test with outputs 0 and 1 admitted
6362        let instructions = steak.get("tm_test").unwrap();
6363        assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
6364
6365        // Outputs should be in storage with real data
6366        let txid = TEST_TXID;
6367        let found = engine
6368            .storage()
6369            .find_output(txid, 0, Some("tm_test"), None, true)
6370            .await
6371            .unwrap()
6372            .expect("Output 0 should be in storage");
6373
6374        assert_eq!(found.txid, TEST_TXID);
6375        assert_eq!(found.satoshis, 26172); // Real value from BRC62 BEEF
6376        assert!(
6377            !found.output_script.is_empty(),
6378            "Script should be populated"
6379        );
6380        assert!(found.beef.is_some(), "BEEF should be stored");
6381        assert!(!found.spent);
6382    }
6383
6384    #[tokio::test]
6385    async fn test_submit_duplicate_is_skipped() {
6386        let engine = make_engine(vec![0]);
6387        let beef = test_tagged_beef(vec!["tm_test"]);
6388
6389        // First submit
6390        let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6391        assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
6392
6393        // Second submit — should be a dupe, no new outputs admitted
6394        let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6395        assert!(steak2["tm_test"].outputs_to_admit.is_empty());
6396    }
6397
6398    #[tokio::test]
6399    async fn test_submit_notifies_lookup_service() {
6400        let engine = make_engine(vec![0]);
6401        let beef = test_tagged_beef(vec!["tm_test"]);
6402
6403        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6404
6405        // Lookup service should have the output
6406        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6407        let answer = engine.lookup(&question, None).await.unwrap();
6408
6409        match answer {
6410            LookupAnswer::OutputList { outputs } => {
6411                assert_eq!(outputs.len(), 1);
6412            }
6413            _ => panic!("Expected OutputList"),
6414        }
6415    }
6416
6417    /// #289: `lookup_with_txids` returns the storage txid per hydrated
6418    /// output, aligned with the OutputList — the aggregated serializer
6419    /// writes it instead of re-hashing the BEEF.
6420    #[tokio::test]
6421    async fn test_lookup_with_txids_aligns_txids_with_outputs() {
6422        let engine = make_engine(vec![0]);
6423        let beef = test_tagged_beef(vec!["tm_test"]);
6424        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6425
6426        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6427        let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
6428        match answer {
6429            LookupAnswer::OutputList { outputs } => {
6430                assert_eq!(outputs.len(), 1);
6431                assert_eq!(txids.len(), outputs.len(), "one txid per output");
6432                assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
6433            }
6434            _ => panic!("Expected OutputList"),
6435        }
6436    }
6437
6438    /// #291: the public sync-response page size is always bounded.
6439    #[test]
6440    fn sync_limit_clamp() {
6441        assert_eq!(
6442            Engine::clamp_sync_limit(None),
6443            Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
6444            "absent limit gets the default page, never unbounded"
6445        );
6446        assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
6447        assert_eq!(
6448            Engine::clamp_sync_limit(Some(u64::MAX)),
6449            Engine::SYNC_RESPONSE_MAX_LIMIT,
6450            "a huge caller-supplied limit is capped"
6451        );
6452    }
6453
6454    #[tokio::test]
6455    async fn test_lookup_unknown_service_errors() {
6456        let engine = make_engine(vec![]);
6457        let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
6458
6459        let result = engine.lookup(&question, None).await;
6460        assert!(result.is_err());
6461        assert!(matches!(
6462            result.unwrap_err(),
6463            EngineError::LookupServiceNotFound(_)
6464        ));
6465    }
6466
6467    #[tokio::test]
6468    async fn test_list_topic_managers() {
6469        let engine = make_engine(vec![]);
6470        let managers = engine.list_topic_managers().await;
6471
6472        assert_eq!(managers.len(), 1);
6473        assert!(managers.contains_key("tm_test"));
6474        assert_eq!(managers["tm_test"].name, "mock-tm");
6475    }
6476
6477    #[tokio::test]
6478    async fn test_list_lookup_service_providers() {
6479        let engine = make_engine(vec![]);
6480        let services = engine.list_lookup_service_providers().await;
6481
6482        assert_eq!(services.len(), 1);
6483        assert!(services.contains_key("ls_test"));
6484        assert_eq!(services["ls_test"].name, "mock-ls");
6485    }
6486
6487    #[tokio::test]
6488    async fn test_get_documentation() {
6489        let engine = make_engine(vec![]);
6490
6491        let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
6492        assert_eq!(tm_docs, "Mock topic manager");
6493
6494        let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
6495        assert_eq!(ls_docs, "Mock lookup service");
6496
6497        let missing = engine
6498            .get_documentation_for_topic_manager("tm_missing")
6499            .await;
6500        assert_eq!(missing, "No documentation found!");
6501    }
6502
6503    #[tokio::test]
6504    async fn test_provide_foreign_sync_response() {
6505        let engine = make_engine(vec![0]);
6506
6507        // Submit data
6508        let beef = test_tagged_beef(vec!["tm_test"]);
6509        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6510
6511        // Query GASP sync
6512        let request = GASPInitialRequest {
6513            version: 1,
6514            since: 0,
6515            limit: Some(100),
6516        };
6517        let response = engine
6518            .provide_foreign_sync_response(&request, "tm_test")
6519            .await
6520            .unwrap();
6521
6522        // One tx admitted with output index 0
6523        assert_eq!(response.utxo_list.len(), 1);
6524        assert_eq!(response.utxo_list[0].txid, TEST_TXID);
6525        assert_eq!(response.since, 0);
6526    }
6527
6528    #[tokio::test]
6529    async fn test_delete_utxo_deep_simple() {
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        let txid = TEST_TXID;
6536        let output = engine
6537            .storage()
6538            .find_output(txid, 0, Some("tm_test"), None, false)
6539            .await
6540            .unwrap()
6541            .unwrap();
6542
6543        // Delete it
6544        engine.delete_utxo_deep(&output, None).await.unwrap();
6545
6546        // Should be gone
6547        let found = engine
6548            .storage()
6549            .find_output(txid, 0, Some("tm_test"), None, false)
6550            .await
6551            .unwrap();
6552        assert!(found.is_none());
6553    }
6554
6555    #[tokio::test]
6556    async fn test_sync_config_defaults_to_ship() {
6557        let engine = make_engine(vec![]);
6558        let sync = &engine.config.sync_configuration;
6559
6560        // tm_test should default to SHIP sync
6561        assert!(sync.contains_key("tm_test"));
6562        assert!(matches!(sync["tm_test"], SyncTarget::Ship));
6563    }
6564
6565    #[tokio::test]
6566    async fn test_multiple_topics_in_single_submit() {
6567        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6568        managers.insert(
6569            "tm_alpha".to_string(),
6570            Box::new(MockTopicManager::admitting(vec![0])),
6571        );
6572        managers.insert(
6573            "tm_beta".to_string(),
6574            Box::new(MockTopicManager::admitting(vec![1])),
6575        );
6576
6577        let engine = Engine::new(
6578            managers,
6579            HashMap::new(),
6580            Box::new(MemoryStorage::new()),
6581            None,
6582            EngineConfig::default(),
6583        );
6584
6585        let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
6586
6587        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6588
6589        assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
6590        assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
6591    }
6592
6593    #[tokio::test]
6594    async fn test_evict_output_with_topic() {
6595        let engine = make_engine(vec![0]);
6596        let beef = test_tagged_beef(vec!["tm_test"]);
6597        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6598
6599        // Output should exist
6600        let found = engine
6601            .storage()
6602            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
6603            .await
6604            .unwrap();
6605        assert!(found.is_some(), "Output should exist before eviction");
6606
6607        // Evict with topic
6608        engine
6609            .evict_output(TEST_TXID, 0, Some("tm_test"))
6610            .await
6611            .unwrap();
6612
6613        // Output should be gone from storage
6614        let found = engine
6615            .storage()
6616            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
6617            .await
6618            .unwrap();
6619        assert!(found.is_none(), "Output should be gone after eviction");
6620
6621        // Lookup service should also have been notified (output evicted)
6622        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6623        let answer = engine.lookup(&question, None).await.unwrap();
6624        match answer {
6625            LookupAnswer::OutputList { outputs } => {
6626                assert!(
6627                    outputs.is_empty(),
6628                    "Lookup should return empty after eviction"
6629                );
6630            }
6631            _ => panic!("Expected OutputList"),
6632        }
6633    }
6634
6635    #[tokio::test]
6636    async fn test_evict_output_without_topic() {
6637        let engine = make_engine(vec![0]);
6638        let beef = test_tagged_beef(vec!["tm_test"]);
6639        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6640
6641        // Evict without topic — should find and remove across all topics
6642        engine.evict_output(TEST_TXID, 0, None).await.unwrap();
6643
6644        let found = engine
6645            .storage()
6646            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
6647            .await
6648            .unwrap();
6649        assert!(
6650            found.is_none(),
6651            "Output should be gone after topic-less eviction"
6652        );
6653    }
6654
6655    #[tokio::test]
6656    async fn test_evict_nonexistent_output_is_ok() {
6657        let engine = make_engine(vec![]);
6658
6659        // Evicting something that doesn't exist should not error
6660        let result = engine
6661            .evict_output("nonexistent_txid", 99, Some("tm_test"))
6662            .await;
6663        assert!(result.is_ok(), "Evicting nonexistent output should succeed");
6664    }
6665
6666    // ── Mock Broadcaster ──────────────────────────────────────────────
6667
6668    use crate::broadcaster::Broadcaster;
6669    use std::sync::Arc;
6670
6671    type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
6672
6673    struct MockBroadcaster {
6674        calls: BroadcastCallLog,
6675    }
6676
6677    impl MockBroadcaster {
6678        fn new() -> Self {
6679            Self {
6680                calls: Arc::new(Mutex::new(Vec::new())),
6681            }
6682        }
6683
6684        #[allow(dead_code, reason = "kept for future test extension")]
6685        fn call_count(&self) -> usize {
6686            self.calls.lock().unwrap().len()
6687        }
6688
6689        #[allow(dead_code, reason = "kept for future test extension")]
6690        fn calls(&self) -> Vec<(String, Vec<String>)> {
6691            self.calls.lock().unwrap().clone()
6692        }
6693    }
6694
6695    #[async_trait(?Send)]
6696    impl Broadcaster for MockBroadcaster {
6697        async fn broadcast_to_host(
6698            &self,
6699            host_url: &str,
6700            tagged_beef: &TaggedBEEF,
6701        ) -> Result<(), String> {
6702            self.calls
6703                .lock()
6704                .unwrap()
6705                .push((host_url.to_string(), tagged_beef.topics.clone()));
6706            Ok(())
6707        }
6708    }
6709
6710    // ── Mock SHIP LookupService ─────────────────────────────────────
6711
6712    /// A mock "ls_ship" lookup service that returns pre-configured UTXOReferences
6713    /// when queried. Used to test SHIP propagation without depending on
6714    /// overlay-discovery in the engine crate's unit tests.
6715    struct MockSHIPLookupService {
6716        /// Maps topic -> list of (txid, output_index) references
6717        records: Mutex<HashMap<String, Vec<UTXOReference>>>,
6718    }
6719
6720    impl MockSHIPLookupService {
6721        fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
6722            Self {
6723                records: Mutex::new(records),
6724            }
6725        }
6726    }
6727
6728    #[async_trait(?Send)]
6729    impl LookupServiceTrait for MockSHIPLookupService {
6730        fn admission_mode(&self) -> AdmissionMode {
6731            AdmissionMode::LockingScript
6732        }
6733
6734        fn spend_notification_mode(&self) -> SpendNotificationMode {
6735            SpendNotificationMode::None
6736        }
6737
6738        async fn output_admitted_by_topic(
6739            &self,
6740            _payload: &OutputAdmittedByTopic,
6741        ) -> Result<(), LookupServiceError> {
6742            Ok(())
6743        }
6744
6745        async fn output_evicted(
6746            &self,
6747            _txid: &str,
6748            _output_index: u32,
6749        ) -> Result<(), LookupServiceError> {
6750            Ok(())
6751        }
6752
6753        async fn lookup(
6754            &self,
6755            question: &LookupQuestion,
6756        ) -> Result<LookupResult, LookupServiceError> {
6757            // Parse the topics from the query
6758            if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
6759                let records = self.records.lock().unwrap();
6760                let mut results = Vec::new();
6761                for topic_val in topics {
6762                    if let Some(topic) = topic_val.as_str() {
6763                        if let Some(refs) = records.get(topic) {
6764                            results.extend(refs.iter().cloned());
6765                        }
6766                    }
6767                }
6768                return Ok(LookupResult::OutputList(results));
6769            }
6770            Ok(LookupResult::OutputList(vec![]))
6771        }
6772
6773        async fn get_documentation(&self) -> String {
6774            "Mock SHIP lookup service".to_string()
6775        }
6776
6777        async fn get_metadata(&self) -> ServiceMetadata {
6778            ServiceMetadata {
6779                name: "mock-ls-ship".to_string(),
6780                ..Default::default()
6781            }
6782        }
6783    }
6784
6785    /// Build a minimal PushDrop locking script for a SHIP advertisement.
6786    fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
6787        use bsv_rs::script::templates::PushDrop;
6788        use bsv_rs::PublicKey;
6789
6790        let fields = vec![
6791            b"SHIP".to_vec(),
6792            vec![0x02; 33], // fake compressed pubkey bytes for identity_key field
6793            domain.as_bytes().to_vec(),
6794            topic.as_bytes().to_vec(),
6795        ];
6796
6797        // PushDrop requires a real compressed public key for the locking script
6798        // Use a well-known test key (generator point G)
6799        let pubkey = PublicKey::from_hex(
6800            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
6801        )
6802        .expect("valid test pubkey");
6803
6804        let pd = PushDrop::new(pubkey, fields);
6805        pd.lock().to_binary()
6806    }
6807
6808    /// Create engine with a broadcaster and a mock SHIP lookup service.
6809    /// The `ship_domains` parameter provides (domain, topic) pairs to pre-populate.
6810    async fn make_engine_with_broadcaster(
6811        admit_indices: Vec<u32>,
6812        broadcaster: MockBroadcaster,
6813        ship_domains: Vec<(&str, &str)>,
6814        hosting_url: Option<&str>,
6815    ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
6816        let calls = broadcaster.calls.clone();
6817
6818        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6819        managers.insert(
6820            "tm_test".to_string(),
6821            Box::new(MockTopicManager::admitting(admit_indices)),
6822        );
6823
6824        let storage = Box::new(MemoryStorage::new());
6825
6826        // Build SHIP records: map each topic to its UTXO references and
6827        // store outputs in storage with PushDrop scripts so the engine can
6828        // parse the domain when broadcasting.
6829        let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
6830
6831        for (domain, topic) in &ship_domains {
6832            // Deterministic fake txid per domain+topic
6833            let fake_txid = format!("{:064x}", {
6834                let mut h: u64 = 0;
6835                for b in domain.bytes().chain(topic.bytes()) {
6836                    h = h.wrapping_mul(31).wrapping_add(u64::from(b));
6837                }
6838                h
6839            });
6840
6841            // Add to SHIP records for the mock lookup service
6842            ship_records
6843                .entry(topic.to_string())
6844                .or_default()
6845                .push(UTXOReference {
6846                    txid: fake_txid.clone(),
6847                    output_index: 0,
6848                });
6849
6850            // Build PushDrop SHIP script and store as an output in main storage
6851            let script = build_ship_pushdrop_script(domain, topic);
6852
6853            let output = Output {
6854                txid: fake_txid,
6855                output_index: 0,
6856                output_script: script,
6857                satoshis: 1,
6858                topic: "tm_ship".to_string(),
6859                spent: false,
6860                outputs_consumed: vec![],
6861                consumed_by: vec![],
6862                beef: None,
6863                block_height: None,
6864                score: None,
6865            };
6866
6867            storage.insert_output(&output).await.unwrap();
6868        }
6869
6870        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
6871        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
6872        lookup_services.insert(
6873            "ls_ship".to_string(),
6874            Box::new(MockSHIPLookupService::with_records(ship_records)),
6875        );
6876
6877        let config = EngineConfig {
6878            hosting_url: hosting_url.map(str::to_string),
6879            ..Default::default()
6880        };
6881
6882        let engine = Engine::with_chain_tracker(
6883            managers,
6884            lookup_services,
6885            storage,
6886            None,
6887            Some(Box::new(broadcaster)),
6888            None,
6889            config,
6890        );
6891
6892        (engine, calls)
6893    }
6894
6895    // ── Broadcaster Tests ─────────────────────────────────────────────
6896
6897    #[tokio::test]
6898    async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
6899        let broadcaster = MockBroadcaster::new();
6900        let (engine, calls) = make_engine_with_broadcaster(
6901            vec![0],
6902            broadcaster,
6903            vec![("https://peer1.example.com", "tm_test")],
6904            Some("https://self.example.com"),
6905        )
6906        .await;
6907
6908        let beef = test_tagged_beef(vec!["tm_test"]);
6909        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6910
6911        // Submit should succeed
6912        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
6913
6914        // Broadcaster should have been called for peer1
6915        let recorded = calls.lock().unwrap();
6916        assert_eq!(
6917            recorded.len(),
6918            1,
6919            "Broadcaster should be called once for the one SHIP peer"
6920        );
6921        assert_eq!(recorded[0].0, "https://peer1.example.com");
6922    }
6923
6924    #[tokio::test]
6925    async fn test_broadcaster_not_called_on_historical_tx() {
6926        let broadcaster = MockBroadcaster::new();
6927        let (engine, calls) = make_engine_with_broadcaster(
6928            vec![0],
6929            broadcaster,
6930            vec![("https://peer1.example.com", "tm_test")],
6931            None,
6932        )
6933        .await;
6934
6935        let beef = test_tagged_beef(vec!["tm_test"]);
6936        let steak = engine
6937            .submit(&beef, SubmitMode::HistoricalTx)
6938            .await
6939            .unwrap();
6940
6941        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
6942
6943        // Broadcaster should NOT have been called
6944        assert_eq!(
6945            calls.lock().unwrap().len(),
6946            0,
6947            "Broadcaster should not be called for historical TX"
6948        );
6949    }
6950
6951    #[tokio::test]
6952    async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
6953        let broadcaster = MockBroadcaster::new();
6954        let (engine, calls) = make_engine_with_broadcaster(
6955            vec![0],
6956            broadcaster,
6957            vec![("https://peer1.example.com", "tm_test")],
6958            None,
6959        )
6960        .await;
6961
6962        let beef = test_tagged_beef(vec!["tm_test"]);
6963        let steak = engine
6964            .submit(&beef, SubmitMode::HistoricalTxNoSpv)
6965            .await
6966            .unwrap();
6967
6968        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
6969
6970        assert_eq!(
6971            calls.lock().unwrap().len(),
6972            0,
6973            "Broadcaster should not be called for historical-tx-no-spv"
6974        );
6975    }
6976
6977    #[tokio::test]
6978    async fn test_broadcaster_skips_self_hosting_url() {
6979        let broadcaster = MockBroadcaster::new();
6980        let (engine, calls) = make_engine_with_broadcaster(
6981            vec![0],
6982            broadcaster,
6983            vec![
6984                ("https://self.example.com", "tm_test"),
6985                ("https://peer2.example.com", "tm_test"),
6986            ],
6987            Some("https://self.example.com"),
6988        )
6989        .await;
6990
6991        let beef = test_tagged_beef(vec!["tm_test"]);
6992        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6993
6994        // Should only broadcast to peer2, not to self
6995        let recorded = calls.lock().unwrap();
6996        assert_eq!(
6997            recorded.len(),
6998            1,
6999            "Should broadcast to peer2 only, skipping self"
7000        );
7001        assert_eq!(recorded[0].0, "https://peer2.example.com");
7002    }
7003
7004    #[tokio::test]
7005    async fn test_broadcaster_not_called_when_no_outputs_admitted() {
7006        let broadcaster = MockBroadcaster::new();
7007        // Topic manager admits nothing
7008        let (engine, calls) = make_engine_with_broadcaster(
7009            vec![],
7010            broadcaster,
7011            vec![("https://peer1.example.com", "tm_test")],
7012            None,
7013        )
7014        .await;
7015
7016        let beef = test_tagged_beef(vec!["tm_test"]);
7017        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7018
7019        assert!(steak["tm_test"].outputs_to_admit.is_empty());
7020
7021        // No outputs admitted -> no broadcast
7022        assert_eq!(
7023            calls.lock().unwrap().len(),
7024            0,
7025            "Broadcaster should not be called when no outputs are admitted"
7026        );
7027    }
7028
7029    #[tokio::test]
7030    async fn test_broadcaster_not_called_when_no_ship_peers() {
7031        let broadcaster = MockBroadcaster::new();
7032        // No SHIP peers registered
7033        let (engine, calls) = make_engine_with_broadcaster(
7034            vec![0],
7035            broadcaster,
7036            vec![], // no SHIP peers
7037            None,
7038        )
7039        .await;
7040
7041        let beef = test_tagged_beef(vec!["tm_test"]);
7042        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7043
7044        assert_eq!(
7045            calls.lock().unwrap().len(),
7046            0,
7047            "Broadcaster should not be called when no SHIP peers exist"
7048        );
7049    }
7050
7051    // ── Configurable SpendNotification mock ─────────────────────────────
7052
7053    /// A mock lookup service whose SpendNotificationMode can be configured.
7054    /// Uses a shared Arc<Mutex<Vec<OutputSpent>>> so the caller can inspect
7055    /// captured payloads after submit().
7056    struct SpendModeLookupService {
7057        mode: SpendNotificationMode,
7058        records: Mutex<Vec<UTXOReference>>,
7059        spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
7060    }
7061
7062    impl SpendModeLookupService {
7063        fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
7064            Self {
7065                mode,
7066                records: Mutex::new(Vec::new()),
7067                spent_payloads: capture,
7068            }
7069        }
7070    }
7071
7072    #[async_trait(?Send)]
7073    impl LookupServiceTrait for SpendModeLookupService {
7074        fn admission_mode(&self) -> AdmissionMode {
7075            AdmissionMode::LockingScript
7076        }
7077
7078        fn spend_notification_mode(&self) -> SpendNotificationMode {
7079            self.mode
7080        }
7081
7082        async fn output_admitted_by_topic(
7083            &self,
7084            payload: &OutputAdmittedByTopic,
7085        ) -> Result<(), LookupServiceError> {
7086            let (txid, oi) = match payload {
7087                OutputAdmittedByTopic::LockingScript {
7088                    txid, output_index, ..
7089                } => (txid.clone(), *output_index),
7090                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
7091                    ("whole".into(), *output_index)
7092                }
7093            };
7094            self.records.lock().unwrap().push(UTXOReference {
7095                txid,
7096                output_index: oi,
7097            });
7098            Ok(())
7099        }
7100
7101        async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
7102            self.spent_payloads.lock().unwrap().push(payload.clone());
7103            Ok(())
7104        }
7105
7106        async fn output_evicted(
7107            &self,
7108            txid: &str,
7109            output_index: u32,
7110        ) -> Result<(), LookupServiceError> {
7111            self.records
7112                .lock()
7113                .unwrap()
7114                .retain(|r| !(r.txid == txid && r.output_index == output_index));
7115            Ok(())
7116        }
7117
7118        async fn lookup(
7119            &self,
7120            _question: &LookupQuestion,
7121        ) -> Result<LookupResult, LookupServiceError> {
7122            Ok(LookupResult::OutputList(
7123                self.records.lock().unwrap().clone(),
7124            ))
7125        }
7126
7127        async fn get_documentation(&self) -> String {
7128            "SpendMode mock lookup service".to_string()
7129        }
7130
7131        async fn get_metadata(&self) -> ServiceMetadata {
7132            ServiceMetadata {
7133                name: "spend-mode-ls".to_string(),
7134                ..Default::default()
7135            }
7136        }
7137    }
7138
7139    /// TXID of the input's source transaction in TEST_BEEF_HEX (the "previous coin").
7140    const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
7141
7142    /// Build an engine with a pre-populated previous output and a SpendModeLookupService.
7143    /// Returns the engine and the shared capture vec for inspecting OutputSpent payloads.
7144    async fn make_spend_mode_engine(
7145        mode: SpendNotificationMode,
7146    ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
7147        let storage = MemoryStorage::new();
7148
7149        // Pre-populate storage with the previous output that TEST_BEEF_HEX's input spends.
7150        let prev_output = Output {
7151            txid: PREVIOUS_TXID.to_string(),
7152            output_index: 0,
7153            output_script: vec![0x76, 0xa9],
7154            satoshis: 26174,
7155            topic: "tm_test".to_string(),
7156            spent: false,
7157            outputs_consumed: vec![],
7158            consumed_by: vec![],
7159            beef: Some(test_beef()),
7160            block_height: None,
7161            score: Some(1000.0),
7162        };
7163        storage.insert_output(&prev_output).await.unwrap();
7164
7165        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7166        managers.insert(
7167            "tm_test".to_string(),
7168            Box::new(MockTopicManager::admitting(vec![0])),
7169        );
7170
7171        let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
7172        let ls = SpendModeLookupService::with_mode(mode, capture.clone());
7173        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7174        lookup_services.insert("ls_test".to_string(), Box::new(ls));
7175
7176        let engine = Engine::new(
7177            managers,
7178            lookup_services,
7179            Box::new(storage),
7180            None,
7181            EngineConfig::default(),
7182        );
7183
7184        (engine, capture)
7185    }
7186
7187    #[tokio::test]
7188    async fn test_spend_notification_mode_script() {
7189        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
7190
7191        let beef = test_tagged_beef(vec!["tm_test"]);
7192        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7193
7194        // The new output should be admitted
7195        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7196
7197        // The lookup service should have received a Script spend notification
7198        let payloads = capture.lock().unwrap().clone();
7199        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7200
7201        match &payloads[0] {
7202            OutputSpent::Script {
7203                txid,
7204                output_index,
7205                topic,
7206                spending_txid,
7207                input_index,
7208                unlocking_script,
7209                sequence_number,
7210                ..
7211            } => {
7212                assert_eq!(txid, PREVIOUS_TXID);
7213                assert_eq!(*output_index, 0);
7214                assert_eq!(topic, "tm_test");
7215                assert_eq!(spending_txid, TEST_TXID);
7216                // The BEEF has a single input at index 0 that spends PREVIOUS_TXID:0
7217                assert_eq!(*input_index, 0);
7218                // The unlocking script should be non-empty (it's a P2PKH scriptSig)
7219                assert!(
7220                    !unlocking_script.is_empty(),
7221                    "Unlocking script should be non-empty"
7222                );
7223                // Standard final sequence number
7224                assert_eq!(*sequence_number, 0xffff_ffff);
7225            }
7226            other => panic!("Expected OutputSpent::Script, got: {other:?}"),
7227        }
7228    }
7229
7230    #[tokio::test]
7231    async fn test_spend_notification_mode_whole_tx() {
7232        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
7233
7234        let beef = test_tagged_beef(vec!["tm_test"]);
7235        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7236
7237        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7238
7239        let payloads = capture.lock().unwrap().clone();
7240        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7241
7242        match &payloads[0] {
7243            OutputSpent::WholeTx {
7244                txid,
7245                output_index,
7246                topic,
7247                spending_atomic_beef,
7248                ..
7249            } => {
7250                assert_eq!(txid, PREVIOUS_TXID);
7251                assert_eq!(*output_index, 0);
7252                assert_eq!(topic, "tm_test");
7253                // The spending BEEF is the entire BEEF we submitted, NAMED
7254                // (atomic prefix) for the lookup service's own re-parse.
7255                let mut named =
7256                    bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
7257                assert!(named.is_atomic());
7258                let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
7259                assert_eq!(
7260                    named.txs.len(),
7261                    submitted.txs.len(),
7262                    "the whole body, never pruned"
7263                );
7264                let tip = crate::subject::subject_txid_of(&mut named).unwrap();
7265                assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
7266            }
7267            other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
7268        }
7269    }
7270
7271    #[tokio::test]
7272    async fn test_spend_notification_mode_txid() {
7273        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
7274
7275        let beef = test_tagged_beef(vec!["tm_test"]);
7276        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7277
7278        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7279
7280        let payloads = capture.lock().unwrap().clone();
7281        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7282
7283        match &payloads[0] {
7284            OutputSpent::Txid {
7285                txid,
7286                output_index,
7287                topic,
7288                spending_txid,
7289            } => {
7290                assert_eq!(txid, PREVIOUS_TXID);
7291                assert_eq!(*output_index, 0);
7292                assert_eq!(topic, "tm_test");
7293                assert_eq!(spending_txid, TEST_TXID);
7294            }
7295            other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
7296        }
7297    }
7298
7299    #[tokio::test]
7300    async fn test_spend_notification_mode_none() {
7301        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
7302
7303        let beef = test_tagged_beef(vec!["tm_test"]);
7304        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7305
7306        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7307
7308        let payloads = capture.lock().unwrap().clone();
7309        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7310
7311        match &payloads[0] {
7312            OutputSpent::None {
7313                txid,
7314                output_index,
7315                topic,
7316            } => {
7317                assert_eq!(txid, PREVIOUS_TXID);
7318                assert_eq!(*output_index, 0);
7319                assert_eq!(topic, "tm_test");
7320            }
7321            other => panic!("Expected OutputSpent::None, got: {other:?}"),
7322        }
7323    }
7324
7325    // ── GASP Sync Tests ───────────────────────────────────────────────
7326
7327    #[tokio::test]
7328    async fn test_start_gasp_sync_empty_config() {
7329        let engine = Engine::new(
7330            HashMap::new(),
7331            HashMap::new(),
7332            Box::new(MemoryStorage::new()),
7333            None,
7334            EngineConfig {
7335                sync_configuration: HashMap::new(),
7336                ..Default::default()
7337            },
7338        );
7339
7340        let result = engine.start_gasp_sync().await.unwrap();
7341        assert!(
7342            result.topics_synced.is_empty(),
7343            "No topics should be synced with empty config"
7344        );
7345    }
7346
7347    #[tokio::test]
7348    async fn test_start_gasp_sync_disabled_topic_skipped() {
7349        let mut sync_config: SyncConfiguration = HashMap::new();
7350        sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
7351
7352        let engine = Engine::new(
7353            HashMap::new(),
7354            HashMap::new(),
7355            Box::new(MemoryStorage::new()),
7356            None,
7357            EngineConfig {
7358                sync_configuration: sync_config,
7359                ..Default::default()
7360            },
7361        );
7362
7363        let result = engine.start_gasp_sync().await.unwrap();
7364        assert!(
7365            result.topics_synced.is_empty(),
7366            "Disabled topics should not appear in results"
7367        );
7368    }
7369
7370    #[tokio::test]
7371    async fn test_start_gasp_sync_peers_config() {
7372        let mut sync_config: SyncConfiguration = HashMap::new();
7373        sync_config.insert(
7374            "tm_test".to_string(),
7375            SyncTarget::Peers(vec![
7376                "https://peer1.example.com".to_string(),
7377                "https://peer2.example.com".to_string(),
7378            ]),
7379        );
7380
7381        let engine = Engine::new(
7382            HashMap::new(),
7383            HashMap::new(),
7384            Box::new(MemoryStorage::new()),
7385            None,
7386            EngineConfig {
7387                sync_configuration: sync_config,
7388                ..Default::default()
7389            },
7390        );
7391
7392        let result = engine.start_gasp_sync().await.unwrap();
7393        assert_eq!(result.topics_synced.len(), 1);
7394
7395        let topic_result = &result.topics_synced["tm_test"];
7396        assert_eq!(topic_result.peers.len(), 2);
7397        assert_eq!(topic_result.sync_type, "peers");
7398        assert!(topic_result.errors.is_empty());
7399    }
7400
7401    #[tokio::test]
7402    async fn test_start_gasp_sync_peers_filters_self() {
7403        let mut sync_config: SyncConfiguration = HashMap::new();
7404        sync_config.insert(
7405            "tm_test".to_string(),
7406            SyncTarget::Peers(vec![
7407                "https://self.example.com".to_string(),
7408                "https://peer2.example.com".to_string(),
7409            ]),
7410        );
7411
7412        let engine = Engine::new(
7413            HashMap::new(),
7414            HashMap::new(),
7415            Box::new(MemoryStorage::new()),
7416            None,
7417            EngineConfig {
7418                hosting_url: Some("https://self.example.com".to_string()),
7419                sync_configuration: sync_config,
7420                ..Default::default()
7421            },
7422        );
7423
7424        let result = engine.start_gasp_sync().await.unwrap();
7425        let topic_result = &result.topics_synced["tm_test"];
7426        assert_eq!(
7427            topic_result.peers.len(),
7428            1,
7429            "Self URL should be filtered out"
7430        );
7431        assert_eq!(topic_result.peers[0], "https://peer2.example.com");
7432    }
7433
7434    #[tokio::test]
7435    async fn test_start_gasp_sync_ship_discovers_peers() {
7436        let broadcaster = MockBroadcaster::new();
7437        let (engine, _calls) = make_engine_with_broadcaster(
7438            vec![0],
7439            broadcaster,
7440            vec![
7441                ("https://peer1.example.com", "tm_test"),
7442                ("https://peer2.example.com", "tm_test"),
7443            ],
7444            Some("https://self.example.com"),
7445        )
7446        .await;
7447
7448        let result = engine.start_gasp_sync().await.unwrap();
7449        assert!(
7450            result.topics_synced.contains_key("tm_test"),
7451            "tm_test should be in sync results"
7452        );
7453
7454        let topic_result = &result.topics_synced["tm_test"];
7455        assert_eq!(topic_result.sync_type, "ship");
7456        assert_eq!(
7457            topic_result.peers.len(),
7458            2,
7459            "Should discover both SHIP peers"
7460        );
7461        assert!(topic_result
7462            .peers
7463            .contains(&"https://peer1.example.com".to_string()));
7464        assert!(topic_result
7465            .peers
7466            .contains(&"https://peer2.example.com".to_string()));
7467    }
7468
7469    #[tokio::test]
7470    async fn test_start_gasp_sync_ship_filters_self() {
7471        let broadcaster = MockBroadcaster::new();
7472        let (engine, _calls) = make_engine_with_broadcaster(
7473            vec![0],
7474            broadcaster,
7475            vec![
7476                ("https://self.example.com", "tm_test"),
7477                ("https://peer2.example.com", "tm_test"),
7478            ],
7479            Some("https://self.example.com"),
7480        )
7481        .await;
7482
7483        let result = engine.start_gasp_sync().await.unwrap();
7484        let topic_result = &result.topics_synced["tm_test"];
7485        assert_eq!(
7486            topic_result.peers.len(),
7487            1,
7488            "Self URL should be filtered out from SHIP discovery"
7489        );
7490        assert_eq!(topic_result.peers[0], "https://peer2.example.com");
7491    }
7492
7493    #[tokio::test]
7494    async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
7495        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7496        managers.insert(
7497            "tm_custom".to_string(),
7498            Box::new(MockTopicManager::admitting(vec![])),
7499        );
7500
7501        let mut sync_config: SyncConfiguration = HashMap::new();
7502        sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
7503
7504        let engine = Engine::new(
7505            managers,
7506            HashMap::new(),
7507            Box::new(MemoryStorage::new()),
7508            None,
7509            EngineConfig {
7510                sync_configuration: sync_config,
7511                ..Default::default()
7512            },
7513        );
7514
7515        let result = engine.start_gasp_sync().await.unwrap();
7516        let topic_result = &result.topics_synced["tm_custom"];
7517        assert!(
7518            topic_result.peers.is_empty(),
7519            "Without ls_ship, SHIP discovery should return no peers"
7520        );
7521    }
7522
7523    #[tokio::test]
7524    async fn test_start_gasp_sync_mixed_config() {
7525        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7526        managers.insert(
7527            "tm_peers".to_string(),
7528            Box::new(MockTopicManager::admitting(vec![])),
7529        );
7530        managers.insert(
7531            "tm_disabled".to_string(),
7532            Box::new(MockTopicManager::admitting(vec![])),
7533        );
7534        managers.insert(
7535            "tm_ship_topic".to_string(),
7536            Box::new(MockTopicManager::admitting(vec![])),
7537        );
7538
7539        let mut sync_config: SyncConfiguration = HashMap::new();
7540        sync_config.insert(
7541            "tm_peers".to_string(),
7542            SyncTarget::Peers(vec!["https://peer.com".to_string()]),
7543        );
7544        sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
7545        sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
7546
7547        let engine = Engine::new(
7548            managers,
7549            HashMap::new(),
7550            Box::new(MemoryStorage::new()),
7551            None,
7552            EngineConfig {
7553                sync_configuration: sync_config,
7554                ..Default::default()
7555            },
7556        );
7557
7558        let result = engine.start_gasp_sync().await.unwrap();
7559        assert_eq!(result.topics_synced.len(), 2);
7560        assert!(result.topics_synced.contains_key("tm_peers"));
7561        assert!(result.topics_synced.contains_key("tm_ship_topic"));
7562        assert!(!result.topics_synced.contains_key("tm_disabled"));
7563
7564        assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
7565        assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
7566        assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
7567    }
7568
7569    #[tokio::test]
7570    async fn test_gasp_sync_result_serialization() {
7571        let mut topics_synced = HashMap::new();
7572        topics_synced.insert(
7573            "tm_test".to_string(),
7574            super::TopicSyncResult {
7575                peers: vec!["https://peer.com".to_string()],
7576                sync_type: "ship".to_string(),
7577                errors: vec![],
7578                pruned_inputs: 0,
7579                discarded_graphs: 0,
7580                anchor_walk_held_graphs: 0,
7581                finalized_graphs: 0,
7582                deadline_dropped_graphs: 0,
7583                cursor_moves: Vec::new(),
7584                deferred_graphs: 0,
7585                resumed_graphs: 0,
7586                converged_graphs: 0,
7587                dropped_graphs: Vec::new(),
7588                stalled_graphs: 0,
7589                held_back_graphs: 0,
7590            },
7591        );
7592
7593        let result = super::GASPSyncResult { topics_synced };
7594        let json = serde_json::to_string(&result).unwrap();
7595        let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
7596
7597        assert_eq!(back.topics_synced.len(), 1);
7598        assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
7599        assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
7600    }
7601
7602    // ── GASP Sync With Factory Tests ─────────────────────────────────
7603
7604    /// Mock GASPRemote for factory tests
7605    struct MockSyncRemote {
7606        utxos: Vec<crate::types::GASPOutput>,
7607    }
7608
7609    #[async_trait(?Send)]
7610    impl crate::gasp::GASPRemote for MockSyncRemote {
7611        async fn get_initial_response(
7612            &self,
7613            request: &crate::types::GASPInitialRequest,
7614        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
7615            let utxos: Vec<crate::types::GASPOutput> = self
7616                .utxos
7617                .iter()
7618                .filter(|u| u.score as u64 >= request.since)
7619                .cloned()
7620                .collect();
7621            Ok(crate::types::GASPInitialResponse {
7622                utxo_list: utxos,
7623                since: request.since,
7624            })
7625        }
7626        async fn get_initial_reply(
7627            &self,
7628            _: &crate::types::GASPInitialResponse,
7629        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
7630            Ok(crate::types::GASPInitialReply {
7631                utxo_list: Vec::new(),
7632            })
7633        }
7634        async fn request_node(
7635            &self,
7636            graph_id: &str,
7637            txid: &str,
7638            output_index: u32,
7639            _: bool,
7640        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
7641            Ok(crate::types::GASPNode {
7642                graph_id: graph_id.to_string(),
7643                raw_tx: format!("rawtx_{txid}"),
7644                output_index,
7645                proof: None,
7646                tx_metadata: None,
7647                output_metadata: None,
7648                inputs: None,
7649            })
7650        }
7651        async fn submit_node(
7652            &self,
7653            _: &crate::types::GASPNode,
7654        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
7655            Ok(None)
7656        }
7657    }
7658
7659    /// Mock factory that creates MockSyncRemote instances
7660    struct MockGASPRemoteFactory;
7661
7662    impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
7663        fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
7664            Box::new(MockSyncRemote {
7665                utxos: vec![
7666                    crate::types::GASPOutput {
7667                        txid: "remote_tx1".to_string(),
7668                        output_index: 0,
7669                        score: 100.0,
7670                    },
7671                    crate::types::GASPOutput {
7672                        txid: "remote_tx2".to_string(),
7673                        output_index: 0,
7674                        score: 200.0,
7675                    },
7676                ],
7677            })
7678        }
7679    }
7680
7681    #[tokio::test]
7682    async fn test_start_gasp_sync_with_factory_runs_sync() {
7683        let mut sync_config: SyncConfiguration = HashMap::new();
7684        sync_config.insert(
7685            "tm_test".to_string(),
7686            SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
7687        );
7688
7689        let mut engine = Engine::new(
7690            HashMap::new(),
7691            HashMap::new(),
7692            Box::new(MemoryStorage::new()),
7693            None,
7694            EngineConfig {
7695                sync_configuration: sync_config,
7696                ..Default::default()
7697            },
7698        );
7699
7700        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
7701
7702        let result = engine.start_gasp_sync().await.unwrap();
7703        assert_eq!(result.topics_synced.len(), 1);
7704
7705        let topic_result = &result.topics_synced["tm_test"];
7706        assert_eq!(topic_result.peers.len(), 1);
7707        assert!(
7708            topic_result.errors.is_empty(),
7709            "Sync should succeed with mock remote"
7710        );
7711
7712        // Verify last_interaction was persisted
7713        let last = engine
7714            .storage()
7715            .get_last_interaction("https://peer1.example.com", "tm_test")
7716            .await
7717            .unwrap();
7718        // The mock remote serves synthetic `rawtx_*` nodes that cannot assemble
7719        // into a real BEEF, so both graphs fail to INGEST (a transient-class
7720        // error). The cursor is therefore capped at 99 — strictly below the
7721        // lowest un-ingested score (remote_tx1@100) — so the next sync re-pulls
7722        // them rather than skipping past. (Pre-#43 this advanced to 200, the
7723        // highest *seen* score, silently stranding both graphs.)
7724        assert_eq!(
7725            last, 99,
7726            "cursor must cap below the lowest un-ingested score, not skip to the seen tip"
7727        );
7728    }
7729
7730    #[tokio::test]
7731    async fn test_start_gasp_sync_with_factory_handles_error() {
7732        struct FailingFactory;
7733
7734        impl crate::gasp::GASPRemoteFactory for FailingFactory {
7735            fn create_remote(
7736                &self,
7737                _peer_url: &str,
7738                _topic: &str,
7739            ) -> Box<dyn crate::gasp::GASPRemote> {
7740                struct FailingRemote;
7741                #[async_trait(?Send)]
7742                impl crate::gasp::GASPRemote for FailingRemote {
7743                    async fn get_initial_response(
7744                        &self,
7745                        _: &crate::types::GASPInitialRequest,
7746                    ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
7747                    {
7748                        Err(crate::gasp::GASPError::RemoteError(
7749                            "connection refused".into(),
7750                        ))
7751                    }
7752                    async fn get_initial_reply(
7753                        &self,
7754                        _: &crate::types::GASPInitialResponse,
7755                    ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
7756                    {
7757                        unreachable!()
7758                    }
7759                    async fn request_node(
7760                        &self,
7761                        _: &str,
7762                        _: &str,
7763                        _: u32,
7764                        _: bool,
7765                    ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
7766                    {
7767                        unreachable!()
7768                    }
7769                    async fn submit_node(
7770                        &self,
7771                        _: &crate::types::GASPNode,
7772                    ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
7773                    {
7774                        unreachable!()
7775                    }
7776                }
7777                Box::new(FailingRemote)
7778            }
7779        }
7780
7781        let mut sync_config: SyncConfiguration = HashMap::new();
7782        sync_config.insert(
7783            "tm_test".to_string(),
7784            SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
7785        );
7786
7787        let mut engine = Engine::new(
7788            HashMap::new(),
7789            HashMap::new(),
7790            Box::new(MemoryStorage::new()),
7791            None,
7792            EngineConfig {
7793                sync_configuration: sync_config,
7794                ..Default::default()
7795            },
7796        );
7797
7798        engine.set_gasp_remote_factory(Box::new(FailingFactory));
7799
7800        let result = engine.start_gasp_sync().await.unwrap();
7801        assert_eq!(result.topics_synced.len(), 1);
7802
7803        let topic_result = &result.topics_synced["tm_test"];
7804        assert_eq!(
7805            topic_result.errors.len(),
7806            1,
7807            "Should have one error for the failing peer"
7808        );
7809        assert!(topic_result.errors[0].contains("connection refused"));
7810    }
7811
7812    #[tokio::test]
7813    async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
7814        let mut sync_config: SyncConfiguration = HashMap::new();
7815        sync_config.insert(
7816            "tm_test".to_string(),
7817            SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
7818        );
7819
7820        // No factory set — should still work (just peer discovery, no sync)
7821        let engine = Engine::new(
7822            HashMap::new(),
7823            HashMap::new(),
7824            Box::new(MemoryStorage::new()),
7825            None,
7826            EngineConfig {
7827                sync_configuration: sync_config,
7828                ..Default::default()
7829            },
7830        );
7831
7832        let result = engine.start_gasp_sync().await.unwrap();
7833        assert_eq!(result.topics_synced.len(), 1);
7834
7835        let topic_result = &result.topics_synced["tm_test"];
7836        assert_eq!(topic_result.peers.len(), 1);
7837        assert!(topic_result.errors.is_empty());
7838    }
7839
7840    #[tokio::test]
7841    async fn test_start_gasp_sync_with_factory_multiple_peers() {
7842        let mut sync_config: SyncConfiguration = HashMap::new();
7843        sync_config.insert(
7844            "tm_test".to_string(),
7845            SyncTarget::Peers(vec![
7846                "https://peer1.example.com".to_string(),
7847                "https://peer2.example.com".to_string(),
7848            ]),
7849        );
7850
7851        let mut engine = Engine::new(
7852            HashMap::new(),
7853            HashMap::new(),
7854            Box::new(MemoryStorage::new()),
7855            None,
7856            EngineConfig {
7857                sync_configuration: sync_config,
7858                ..Default::default()
7859            },
7860        );
7861
7862        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
7863
7864        let result = engine.start_gasp_sync().await.unwrap();
7865        let topic_result = &result.topics_synced["tm_test"];
7866        assert_eq!(topic_result.peers.len(), 2);
7867        assert!(topic_result.errors.is_empty());
7868
7869        // Both peers should have their last_interaction updated
7870        let last1 = engine
7871            .storage()
7872            .get_last_interaction("https://peer1.example.com", "tm_test")
7873            .await
7874            .unwrap();
7875        let last2 = engine
7876            .storage()
7877            .get_last_interaction("https://peer2.example.com", "tm_test")
7878            .await
7879            .unwrap();
7880        // Capped at 99 (below the lowest un-ingested score@100), not 200 — the
7881        // synthetic mock graphs fail to ingest, so the cursor must not skip past
7882        // them. See `test_start_gasp_sync_with_factory_runs_sync` (#43 gap-guard).
7883        assert_eq!(last1, 99);
7884        assert_eq!(last2, 99);
7885    }
7886
7887    // ── Per-peer budget + dead-peer quarantine (bsv-low#302) ──────────
7888
7889    /// A remote whose initial request HANGS forever — models the dead
7890    /// ephemeral peers (ngrok tunnels) whose fetch never returns, the #257
7891    /// root cause the per-peer budget exists for.
7892    struct HangingRemote;
7893
7894    #[async_trait(?Send)]
7895    impl crate::gasp::GASPRemote for HangingRemote {
7896        async fn get_initial_response(
7897            &self,
7898            _: &crate::types::GASPInitialRequest,
7899        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
7900            std::future::pending::<()>().await;
7901            unreachable!()
7902        }
7903        async fn get_initial_reply(
7904            &self,
7905            _: &crate::types::GASPInitialResponse,
7906        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
7907            unreachable!()
7908        }
7909        async fn request_node(
7910            &self,
7911            _: &str,
7912            _: &str,
7913            _: u32,
7914            _: bool,
7915        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
7916            unreachable!()
7917        }
7918        async fn submit_node(
7919            &self,
7920            _: &crate::types::GASPNode,
7921        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
7922            unreachable!()
7923        }
7924    }
7925
7926    /// Factory that HANGS for `hang.example.com` peers and serves the normal
7927    /// mock sync remote for everyone else, counting `create_remote` calls
7928    /// (a quarantine-skipped peer never reaches the factory).
7929    struct SelectiveHangFactory {
7930        created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
7931    }
7932
7933    impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
7934        fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
7935            self.created.borrow_mut().push(peer_url.to_string());
7936            if peer_url.contains("hang.example.com") {
7937                Box::new(HangingRemote)
7938            } else {
7939                Box::new(MockSyncRemote {
7940                    utxos: vec![crate::types::GASPOutput {
7941                        txid: "remote_tx1".to_string(),
7942                        output_index: 0,
7943                        score: 100.0,
7944                    }],
7945                })
7946            }
7947        }
7948    }
7949
7950    /// The deterministic "instant deadline" sleep factory: `race_or_deadline`
7951    /// polls the sync future FIRST, so an in-memory sync that never yields
7952    /// still completes; only a future that actually returns Pending (the
7953    /// hanging remote) loses the race.
7954    fn instant_deadline() -> crate::engine::SleepFactory {
7955        std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
7956    }
7957
7958    fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
7959        let mut sync_config: SyncConfiguration = HashMap::new();
7960        sync_config.insert(
7961            "tm_test".to_string(),
7962            SyncTarget::Peers(vec![
7963                "https://hang.example.com".to_string(),
7964                "https://good.example.com".to_string(),
7965            ]),
7966        );
7967        Engine::new(
7968            HashMap::new(),
7969            HashMap::new(),
7970            Box::new(store),
7971            None,
7972            EngineConfig {
7973                sync_configuration: sync_config,
7974                ..Default::default()
7975            },
7976        )
7977    }
7978
7979    #[tokio::test]
7980    async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
7981        let store = std::rc::Rc::new(MemoryStorage::new());
7982        let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
7983        let mut engine = two_peer_engine(store.clone());
7984        engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
7985            created: created.clone(),
7986        }));
7987        engine.set_peer_sync_budget(instant_deadline(), 1);
7988
7989        let result = engine.start_gasp_sync().await.unwrap();
7990        let topic_result = &result.topics_synced["tm_test"];
7991
7992        // The hanging peer was dropped LOUDLY (an error entry) and the loop
7993        // CONTINUED: the good peer was still attempted and completed.
7994        assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
7995        assert!(
7996            topic_result.errors[0].contains("budget"),
7997            "{:?}",
7998            topic_result.errors
7999        );
8000        assert_eq!(
8001            created.borrow().as_slice(),
8002            ["https://hang.example.com", "https://good.example.com"],
8003            "both peers attempted this tick"
8004        );
8005
8006        // Cursor semantics (the #257 gate property): a timed-out peer's
8007        // cursor NEVER advances; the completed peer's does.
8008        let hang_cursor = store
8009            .get_last_interaction("https://hang.example.com", "tm_test")
8010            .await
8011            .unwrap();
8012        assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
8013        let good_cursor = store
8014            .get_last_interaction("https://good.example.com", "tm_test")
8015            .await
8016            .unwrap();
8017        assert!(good_cursor > 0, "completed peer's cursor advances");
8018
8019        // Health bookkeeping: timeout = failure, completion = success.
8020        let hang_health = store
8021            .get_peer_sync_health("hang.example.com", "tm_test")
8022            .await
8023            .unwrap();
8024        assert_eq!(hang_health.consecutive_failures, 1);
8025        let good_health = store
8026            .get_peer_sync_health("good.example.com", "tm_test")
8027            .await
8028            .unwrap();
8029        assert_eq!(good_health.consecutive_failures, 0);
8030    }
8031
8032    #[tokio::test]
8033    async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
8034        let store = std::rc::Rc::new(MemoryStorage::new());
8035        let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8036
8037        let mut sync_config: SyncConfiguration = HashMap::new();
8038        sync_config.insert(
8039            "tm_test".to_string(),
8040            SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
8041        );
8042        let mut engine = Engine::new(
8043            HashMap::new(),
8044            HashMap::new(),
8045            Box::new(store.clone()),
8046            None,
8047            EngineConfig {
8048                sync_configuration: sync_config,
8049                ..Default::default()
8050            },
8051        );
8052        engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
8053            created: created.clone(),
8054        }));
8055        engine.set_peer_sync_budget(instant_deadline(), 1);
8056
8057        // Fail the peer up to (but not past) the quarantine threshold — every
8058        // tick attempts it (advance the clock between ticks so the attempts
8059        // are distinguishable in age).
8060        for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
8061            engine.start_gasp_sync().await.unwrap();
8062            store.advance_clock(900);
8063            assert_eq!(
8064                created.borrow().len() as u64,
8065                i + 1,
8066                "peer attempted while below the threshold"
8067            );
8068        }
8069        let health = store
8070            .get_peer_sync_health("hang.example.com", "tm_test")
8071            .await
8072            .unwrap();
8073        assert_eq!(
8074            health.consecutive_failures,
8075            crate::gasp::PEER_QUARANTINE_THRESHOLD
8076        );
8077
8078        // Next tick: QUARANTINED — the factory is never consulted, no error
8079        // is emitted, and the attempt count does not grow (a skip is not an
8080        // attempt, so the re-probe window keeps aging).
8081        let result = engine.start_gasp_sync().await.unwrap();
8082        assert!(result.topics_synced["tm_test"].errors.is_empty());
8083        assert_eq!(
8084            created.borrow().len() as u64,
8085            crate::gasp::PEER_QUARANTINE_THRESHOLD,
8086            "quarantined peer must be skipped"
8087        );
8088
8089        // Age past the re-probe window: exactly one fresh probe is allowed.
8090        store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
8091        engine.start_gasp_sync().await.unwrap();
8092        assert_eq!(
8093            created.borrow().len() as u64,
8094            crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
8095            "re-probe attempted after the window"
8096        );
8097        // The probe failed again → failure count kept growing → re-armed.
8098        let health = store
8099            .get_peer_sync_health("hang.example.com", "tm_test")
8100            .await
8101            .unwrap();
8102        assert_eq!(
8103            health.consecutive_failures,
8104            crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
8105        );
8106        let result = engine.start_gasp_sync().await.unwrap();
8107        assert!(
8108            result.topics_synced["tm_test"].errors.is_empty(),
8109            "re-quarantined"
8110        );
8111
8112        // The peer comes back to life: age to the next re-probe, then answer
8113        // like a healthy peer → SUCCESS resets the count to 0 (full
8114        // re-admission — quarantine is never a deletion).
8115        store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
8116        // Swap the factory for one that serves the healthy mock for this URL.
8117        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
8118        let result = engine.start_gasp_sync().await.unwrap();
8119        assert!(result.topics_synced["tm_test"].errors.is_empty());
8120        let health = store
8121            .get_peer_sync_health("hang.example.com", "tm_test")
8122            .await
8123            .unwrap();
8124        assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
8125    }
8126
8127    // ── Mock ARC Broadcaster ─────────────────────────────────────────
8128
8129    use crate::broadcaster::ArcBroadcaster;
8130
8131    struct MockArcBroadcaster {
8132        calls: Arc<Mutex<Vec<String>>>,
8133        should_fail: bool,
8134    }
8135
8136    impl MockArcBroadcaster {
8137        fn new() -> Self {
8138            Self {
8139                calls: Arc::new(Mutex::new(Vec::new())),
8140                should_fail: false,
8141            }
8142        }
8143
8144        fn failing() -> Self {
8145            Self {
8146                calls: Arc::new(Mutex::new(Vec::new())),
8147                should_fail: true,
8148            }
8149        }
8150    }
8151
8152    #[async_trait(?Send)]
8153    impl ArcBroadcaster for MockArcBroadcaster {
8154        async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
8155            self.calls.lock().unwrap().push(raw_tx_hex.to_string());
8156            if self.should_fail {
8157                Err("mock ARC failure".to_string())
8158            } else {
8159                Ok("mock_txid_from_arc".to_string())
8160            }
8161        }
8162    }
8163
8164    /// Create engine with an ARC broadcaster (and optionally a SHIP broadcaster).
8165    fn make_engine_with_arc(
8166        admit_indices: Vec<u32>,
8167        arc: MockArcBroadcaster,
8168    ) -> (Engine, Arc<Mutex<Vec<String>>>) {
8169        let arc_calls = arc.calls.clone();
8170
8171        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8172        managers.insert(
8173            "tm_test".to_string(),
8174            Box::new(MockTopicManager::admitting(admit_indices)),
8175        );
8176
8177        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8178        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
8179
8180        let storage = Box::new(MemoryStorage::new());
8181
8182        let engine = Engine::with_all(
8183            managers,
8184            lookup_services,
8185            storage,
8186            None,
8187            None,
8188            Some(Box::new(arc)),
8189            None,
8190            EngineConfig::default(),
8191        );
8192
8193        (engine, arc_calls)
8194    }
8195
8196    // ── ARC Broadcaster Tests ────────────────────────────────────────
8197
8198    #[tokio::test]
8199    async fn test_arc_broadcaster_called_on_current_tx() {
8200        let arc = MockArcBroadcaster::new();
8201        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
8202
8203        let beef = test_tagged_beef(vec!["tm_test"]);
8204        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8205
8206        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8207
8208        let calls = arc_calls.lock().unwrap();
8209        assert_eq!(
8210            calls.len(),
8211            1,
8212            "ARC broadcaster should be called once for CurrentTx"
8213        );
8214        assert!(
8215            !calls[0].is_empty(),
8216            "ARC should receive non-empty raw tx hex"
8217        );
8218    }
8219
8220    #[tokio::test]
8221    async fn test_arc_broadcaster_not_called_on_historical_tx() {
8222        let arc = MockArcBroadcaster::new();
8223        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
8224
8225        let beef = test_tagged_beef(vec!["tm_test"]);
8226        engine
8227            .submit(&beef, SubmitMode::HistoricalTx)
8228            .await
8229            .unwrap();
8230
8231        assert_eq!(
8232            arc_calls.lock().unwrap().len(),
8233            0,
8234            "ARC broadcaster should NOT be called for HistoricalTx"
8235        );
8236    }
8237
8238    #[tokio::test]
8239    async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
8240        let arc = MockArcBroadcaster::new();
8241        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
8242
8243        let beef = test_tagged_beef(vec!["tm_test"]);
8244        engine
8245            .submit(&beef, SubmitMode::HistoricalTxNoSpv)
8246            .await
8247            .unwrap();
8248
8249        assert_eq!(
8250            arc_calls.lock().unwrap().len(),
8251            0,
8252            "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
8253        );
8254    }
8255
8256    #[tokio::test]
8257    async fn test_arc_broadcast_failure_does_not_fail_submit() {
8258        let arc = MockArcBroadcaster::failing();
8259        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
8260
8261        let beef = test_tagged_beef(vec!["tm_test"]);
8262        // submit should succeed even when ARC fails
8263        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8264
8265        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8266
8267        // ARC was called (and failed), but submit still succeeded
8268        assert_eq!(
8269            arc_calls.lock().unwrap().len(),
8270            1,
8271            "ARC broadcaster should be attempted even if it will fail"
8272        );
8273    }
8274
8275    #[tokio::test]
8276    async fn test_arc_broadcaster_not_present_still_works() {
8277        // Engine without ARC broadcaster — should work fine
8278        let engine = make_engine(vec![0]);
8279
8280        let beef = test_tagged_beef(vec!["tm_test"]);
8281        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8282
8283        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8284    }
8285
8286    // ── Sync advertisements URL validation ─────────────────────────────
8287
8288    /// Mock Advertiser that tracks created advertisements via shared state.
8289    struct TrackingAdvertiser {
8290        created: Arc<Mutex<Vec<AdvertisementData>>>,
8291        existing_ship: Vec<Advertisement>,
8292        existing_slap: Vec<Advertisement>,
8293        /// Topics the returned TaggedBEEF is tagged with — a topic with no
8294        /// registered manager forces the LOCAL submit to fail (#320 3a);
8295        /// several topics exercise independent per-topic admission (D1).
8296        tag_topics: Vec<String>,
8297        /// Simulate a create_advertisements failure (#320 3a).
8298        create_fails: bool,
8299        /// Simulate a find_all_advertisements failure (#320 M2).
8300        find_fails: bool,
8301        /// Return the v1 no-op EMPTY TaggedBEEF from revoke (#320 L2).
8302        revoke_empty: bool,
8303    }
8304
8305    impl TrackingAdvertiser {
8306        fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
8307            let created = Arc::new(Mutex::new(Vec::new()));
8308            (
8309                Self {
8310                    created: created.clone(),
8311                    existing_ship: vec![],
8312                    existing_slap: vec![],
8313                    tag_topics: vec!["tm_test".to_string()],
8314                    create_fails: false,
8315                    find_fails: false,
8316                    revoke_empty: false,
8317                },
8318                created,
8319            )
8320        }
8321    }
8322
8323    use crate::advertiser::{Advertiser, AdvertiserError};
8324
8325    #[async_trait(?Send)]
8326    impl Advertiser for TrackingAdvertiser {
8327        async fn create_advertisements(
8328            &self,
8329            ads: &[AdvertisementData],
8330        ) -> Result<TaggedBEEF, AdvertiserError> {
8331            if self.create_fails {
8332                return Err(AdvertiserError::CreationFailed(
8333                    "mock create refused".into(),
8334                ));
8335            }
8336            self.created.lock().unwrap().extend(ads.iter().cloned());
8337            Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
8338        }
8339
8340        async fn find_all_advertisements(
8341            &self,
8342            protocol: Protocol,
8343        ) -> Result<Vec<Advertisement>, AdvertiserError> {
8344            if self.find_fails {
8345                return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
8346            }
8347            match protocol {
8348                Protocol::Ship => Ok(self.existing_ship.clone()),
8349                Protocol::Slap => Ok(self.existing_slap.clone()),
8350            }
8351        }
8352
8353        async fn revoke_advertisements(
8354            &self,
8355            _ads: &[Advertisement],
8356        ) -> Result<TaggedBEEF, AdvertiserError> {
8357            if self.revoke_empty {
8358                return Ok(TaggedBEEF::new(vec![], vec![]));
8359            }
8360            Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
8361        }
8362
8363        fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
8364            None
8365        }
8366    }
8367
8368    /// TS: "Sync advertisements URL validation"
8369    /// sync_advertisements should be a no-op when hosting_url is empty string,
8370    /// and should skip suppressed topics (tm_ship/tm_slap) when configured.
8371    #[tokio::test]
8372    async fn test_sync_advertisements_url_validation() {
8373        // Case 1: Empty hosting URL — should return early without creating ads
8374        let (adv, created) = TrackingAdvertiser::new();
8375
8376        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8377        managers.insert(
8378            "tm_test".to_string(),
8379            Box::new(MockTopicManager::admitting(vec![0])),
8380        );
8381
8382        let engine = Engine::new(
8383            managers,
8384            HashMap::new(),
8385            Box::new(MemoryStorage::new()),
8386            Some(Box::new(adv)),
8387            EngineConfig {
8388                hosting_url: Some(String::new()), // empty string — invalid
8389                ..Default::default()
8390            },
8391        );
8392
8393        engine.sync_advertisements().await.unwrap();
8394        assert!(
8395            created.lock().unwrap().is_empty(),
8396            "Empty hosting URL should skip advertisement creation"
8397        );
8398
8399        // Case 2: Valid hosting URL — should create SHIP advertisements for non-suppressed topics
8400        let (adv2, created2) = TrackingAdvertiser::new();
8401
8402        let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8403        managers2.insert(
8404            "tm_test".to_string(),
8405            Box::new(MockTopicManager::admitting(vec![0])),
8406        );
8407        // Also register tm_ship to verify it gets suppressed
8408        managers2.insert(
8409            "tm_ship".to_string(),
8410            Box::new(MockTopicManager::admitting(vec![])),
8411        );
8412
8413        let engine2 = Engine::new(
8414            managers2,
8415            HashMap::new(),
8416            Box::new(MemoryStorage::new()),
8417            Some(Box::new(adv2)),
8418            EngineConfig {
8419                hosting_url: Some("https://valid.example.com".to_string()),
8420                suppress_default_sync_advertisements: true,
8421                ..Default::default()
8422            },
8423        );
8424
8425        engine2.sync_advertisements().await.unwrap();
8426        let ads = created2.lock().unwrap();
8427        // tm_ship should be suppressed, only tm_test should get an advertisement
8428        assert!(
8429            ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
8430            "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
8431        );
8432        assert!(
8433            ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
8434            "tm_test should get a SHIP advertisement"
8435        );
8436    }
8437
8438    // ── sync_advertisements report (bsv-low #320 defect 3a) ────────────
8439    //
8440    // Pre-#320 the engine returned `Ok(())` unconditionally: a failed
8441    // create or a failed LOCAL submit was `error!`-logged and invisible to
8442    // the caller — the admin route said `success` while the node's own
8443    // ls_ship/ls_slap never gained its ads, so every cycle re-created the
8444    // whole set. These cells pin the report contract.
8445
8446    fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
8447        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8448        managers.insert(
8449            "tm_test".to_string(),
8450            Box::new(MockTopicManager::admitting(vec![0])),
8451        );
8452        Engine::new(
8453            managers,
8454            HashMap::new(),
8455            Box::new(MemoryStorage::new()),
8456            Some(Box::new(adv)),
8457            EngineConfig {
8458                hosting_url: Some("https://valid.example.com".to_string()),
8459                suppress_default_sync_advertisements: true,
8460                ..Default::default()
8461            },
8462        )
8463    }
8464
8465    #[tokio::test]
8466    async fn sync_report_counts_admitted_outputs_on_success() {
8467        let (adv, _) = TrackingAdvertiser::new();
8468        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
8469        assert!(report.ok(), "clean run must report ok: {report:?}");
8470        assert_eq!(report.to_create, 1, "tm_test was missing");
8471        assert_eq!(
8472            report.admitted.get("tm_test"),
8473            Some(&1),
8474            "the STEAK's admitted count must be surfaced: {report:?}"
8475        );
8476    }
8477
8478    #[tokio::test]
8479    async fn sync_report_surfaces_local_submit_failure() {
8480        // The TaggedBEEF comes back tagged for a topic with no registered
8481        // manager → the LOCAL submit fails. That failure must be in the
8482        // report, not swallowed (the live silent-failure shape).
8483        let (mut adv, _) = TrackingAdvertiser::new();
8484        adv.tag_topics = vec!["tm_unregistered".to_string()];
8485        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
8486        assert!(!report.ok(), "a failed local submit must not report ok");
8487        assert_eq!(report.to_create, 1);
8488        let submit_error = report.submit_error.as_deref().unwrap_or_default();
8489        assert!(
8490            submit_error.contains("tm_unregistered"),
8491            "verbatim engine error expected, got: {submit_error}"
8492        );
8493        assert!(report.admitted.is_empty());
8494    }
8495
8496    #[tokio::test]
8497    async fn sync_report_surfaces_create_failure() {
8498        let (mut adv, _) = TrackingAdvertiser::new();
8499        adv.create_fails = true;
8500        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
8501        assert!(!report.ok());
8502        assert!(
8503            report
8504                .create_error
8505                .as_deref()
8506                .unwrap_or_default()
8507                .contains("mock create refused"),
8508            "{report:?}"
8509        );
8510        assert!(report.submit_error.is_none(), "create failed before submit");
8511    }
8512
8513    #[tokio::test]
8514    async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
8515        // A node whose own storage already lists its ads must attempt ZERO
8516        // creates — the convergence direction of #320 defect 3 (live, the
8517        // missing self-admission made every cycle re-create all 17 ads).
8518        let (mut adv, created) = TrackingAdvertiser::new();
8519        adv.existing_ship = vec![Advertisement {
8520            protocol: Protocol::Ship,
8521            identity_key: "02aa".to_string(),
8522            domain: "https://valid.example.com".to_string(),
8523            topic_or_service: "tm_test".to_string(),
8524            beef: None,
8525            output_index: None,
8526        }];
8527        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
8528        assert!(report.ok(), "{report:?}");
8529        assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
8530        assert!(created.lock().unwrap().is_empty());
8531        assert!(
8532            report.effective(),
8533            "converged-noop is the one zero-admit shape that IS success"
8534        );
8535    }
8536
8537    #[tokio::test]
8538    async fn sync_report_zero_admit_is_ok_but_not_effective() {
8539        // #320 M1: the local submit SUCCEEDS but the topic manager admits
8540        // ZERO outputs — a non-error refusal. The ads never enter our own
8541        // ls_ship/ls_slap, so the diff re-creates (re-pays) the full set
8542        // next cycle. `ok()` (errors-only) stays true; `effective()` must
8543        // refuse, and the route keys success on `effective()`.
8544        let (adv, _) = TrackingAdvertiser::new();
8545        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8546        managers.insert(
8547            "tm_test".to_string(),
8548            Box::new(MockTopicManager::admitting(vec![])), // refuses everything
8549        );
8550        let engine = Engine::new(
8551            managers,
8552            HashMap::new(),
8553            Box::new(MemoryStorage::new()),
8554            Some(Box::new(adv)),
8555            EngineConfig {
8556                hosting_url: Some("https://valid.example.com".to_string()),
8557                suppress_default_sync_advertisements: true,
8558                ..Default::default()
8559            },
8560        );
8561        let report = engine.sync_advertisements().await.unwrap();
8562        assert!(report.ok(), "no ERROR occurred: {report:?}");
8563        assert_eq!(report.to_create, 1);
8564        assert_eq!(report.admitted_total(), 0, "{report:?}");
8565        assert!(
8566            !report.effective(),
8567            "zero admitted on a non-empty create must not read as success: {report:?}"
8568        );
8569    }
8570
8571    #[tokio::test]
8572    async fn sync_report_partial_admit_is_not_effective() {
8573        // Delta D1: TWO topics in one submitted tx, per-topic admission
8574        // independent — tm_test admits its output, tm_refuse refuses all.
8575        // admitted_total (1) > 0 but < to_create (2): the refused ads
8576        // would be re-created (re-paid) every cycle, so this must NOT
8577        // read as success.
8578        let (mut adv, _) = TrackingAdvertiser::new();
8579        adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
8580        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8581        managers.insert(
8582            "tm_test".to_string(),
8583            Box::new(MockTopicManager::admitting(vec![0])),
8584        );
8585        managers.insert(
8586            "tm_refuse".to_string(),
8587            Box::new(MockTopicManager::admitting(vec![])), // refuses everything
8588        );
8589        let engine = Engine::new(
8590            managers,
8591            HashMap::new(),
8592            Box::new(MemoryStorage::new()),
8593            Some(Box::new(adv)),
8594            EngineConfig {
8595                hosting_url: Some("https://valid.example.com".to_string()),
8596                suppress_default_sync_advertisements: true,
8597                ..Default::default()
8598            },
8599        );
8600        let report = engine.sync_advertisements().await.unwrap();
8601        assert!(report.ok(), "no ERROR occurred: {report:?}");
8602        assert_eq!(report.to_create, 2, "{report:?}");
8603        assert_eq!(report.admitted_total(), 1, "{report:?}");
8604        assert!(
8605            !report.effective(),
8606            "a partial admit must not read as success: {report:?}"
8607        );
8608    }
8609
8610    #[tokio::test]
8611    async fn sync_report_lookup_failure_refuses_creation() {
8612        // #320 M2: a transient read failure must never become "current ads
8613        // = none" and re-create (re-pay) the whole set — creation is
8614        // REFUSED and the failure lands in the report.
8615        let (mut adv, created) = TrackingAdvertiser::new();
8616        adv.find_fails = true;
8617        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
8618        assert!(!report.ok(), "{report:?}");
8619        assert!(!report.effective());
8620        assert!(
8621            report
8622                .lookup_error
8623                .as_deref()
8624                .unwrap_or_default()
8625                .contains("mock lookup refused"),
8626            "{report:?}"
8627        );
8628        assert_eq!(report.to_create, 0, "creation refused, not attempted");
8629        assert!(
8630            created.lock().unwrap().is_empty(),
8631            "no create call on a blind read"
8632        );
8633        assert!(report.submit_error.is_none());
8634    }
8635
8636    #[tokio::test]
8637    async fn sync_report_counts_skipped_revocations() {
8638        // #320 L2: `to_revoke: N` with a v1 no-op advertiser must be
8639        // distinguishable from a revoke that actually ran.
8640        let (mut adv, _) = TrackingAdvertiser::new();
8641        adv.revoke_empty = true;
8642        adv.existing_ship = vec![
8643            Advertisement {
8644                protocol: Protocol::Ship,
8645                identity_key: "02aa".to_string(),
8646                domain: "https://valid.example.com".to_string(),
8647                topic_or_service: "tm_test".to_string(),
8648                beef: None,
8649                output_index: None,
8650            },
8651            Advertisement {
8652                protocol: Protocol::Ship,
8653                identity_key: "02aa".to_string(),
8654                domain: "https://valid.example.com".to_string(),
8655                topic_or_service: "tm_stale".to_string(), // no longer configured
8656                beef: None,
8657                output_index: None,
8658            },
8659        ];
8660        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
8661        assert!(report.ok(), "{report:?}");
8662        assert_eq!(report.to_revoke, 1, "tm_stale is stale");
8663        assert_eq!(
8664            report.revoke_skipped, 1,
8665            "the v1 no-op must be visible, not silent: {report:?}"
8666        );
8667        assert!(report.revoke_error.is_none());
8668        assert!(report.revoke_submit_error.is_none());
8669    }
8670
8671    // ── complete_missing_proofs (#130) ─────────────────────────────────
8672
8673    use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
8674    use std::cell::Cell;
8675
8676    /// A single raw mainnet tx (no proof) — used to build a proofless BEEF.
8677    const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
8678
8679    /// Build a proofless single-tx BEEF + return `(beef_bytes, txid)`.
8680    fn proofless_beef() -> (Vec<u8>, String) {
8681        use bsv_rs::transaction::{Beef, Transaction};
8682        let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
8683        let txid = tx.id();
8684        let mut beef = Beef::new();
8685        beef.merge_transaction(tx);
8686        (beef.to_binary(), txid)
8687    }
8688
8689    // ── #284: stitching a proof must never rebuild the stored BEEF ─────────
8690
8691    /// The corruption shape observed live on rust-beta: a stored BEEF whose
8692    /// SUBJECT is not the last tx in wire order. The old rewrite went through
8693    /// `Transaction::from_beef(bytes, None)`, whose None arm picks
8694    /// `txs.last()` — the PARENT here — and `to_beef(true)` then serialized the
8695    /// parent's world, dropping the subject from its own row. TEST_BEEF_HEX is
8696    /// exactly a two-tx chain (parent + child spending it), so re-encoding it
8697    /// child-first reproduces the trigger byte-for-byte.
8698    fn subject_not_last_beef() -> (Vec<u8>, String, String) {
8699        use bsv_rs::transaction::Beef;
8700        let parsed = Beef::from_binary(&test_beef()).unwrap();
8701        let parent = parsed.txs[0].tx().unwrap().clone();
8702        let child = parsed.txs[1].tx().unwrap().clone();
8703        let (parent_id, child_id) = (parent.id(), child.id());
8704        assert_ne!(parent_id, child_id);
8705
8706        // Serialize via to_writer, NOT to_binary: to_binary re-sorts
8707        // parents-first (subject last), which is precisely why bsv_rs's own
8708        // output never triggers the bug. The corrupt rows held WALLET
8709        // serialized bytes stored verbatim at admit time, whose wire order is
8710        // whatever the wallet emitted — model that by writing the child first.
8711        let mut beef = Beef::new();
8712        beef.merge_transaction(child); // subject FIRST — the trigger
8713        beef.merge_transaction(parent);
8714        let mut w = bsv_rs::primitives::encoding::Writer::new();
8715        beef.to_writer(&mut w);
8716        let bytes = w.into_bytes();
8717
8718        // The premise the whole test rests on: wire order preserved, subject
8719        // genuinely not last.
8720        let reread = Beef::from_binary(&bytes).unwrap();
8721        assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
8722        (bytes, child_id, parent_id)
8723    }
8724
8725    #[test]
8726    fn stitch_preserves_the_subject_when_it_is_not_last() {
8727        use bsv_rs::transaction::{Beef, MerklePath};
8728        let (stored, child_id, parent_id) = subject_not_last_beef();
8729        let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
8730
8731        let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
8732            .expect("stitch must succeed on a beef that contains the txid");
8733
8734        let after = Beef::from_binary(&out).unwrap();
8735        assert!(
8736            after.find_txid(&child_id).is_some(),
8737            "the SUBJECT must survive its own proof-completion"
8738        );
8739        assert!(
8740            after.find_txid(&parent_id).is_some(),
8741            "the ancestry must survive too"
8742        );
8743        assert!(
8744            after.find_bump(&child_id).is_some(),
8745            "the stitched bump must prove the subject"
8746        );
8747        assert!(
8748            after.find_bump(&parent_id).is_none(),
8749            "the parent must NOT be claimed by the child's bump"
8750        );
8751    }
8752
8753    /// The old path, reproduced against the same input, to pin WHY the new one
8754    /// exists: it demonstrably drops the subject. If bsv_rs ever changes
8755    /// `from_beef(_, None)` to honor the subject, this starts failing and the
8756    /// comment trail can be revisited — until then it documents the hazard.
8757    #[test]
8758    fn the_old_round_trip_demonstrably_dropped_the_subject() {
8759        use bsv_rs::transaction::{Beef, Transaction};
8760        let (stored, child_id, _parent_id) = subject_not_last_beef();
8761        let tx = Transaction::from_beef(&stored, None).unwrap();
8762        assert_ne!(
8763            tx.id(),
8764            child_id,
8765            "from_beef(None) picks txs.last(), not the subject — the trigger"
8766        );
8767        let rebuilt = tx.to_beef(true).unwrap();
8768        let reparsed = Beef::from_binary(&rebuilt).unwrap();
8769        assert!(
8770            reparsed.find_txid(&child_id).is_none(),
8771            "the rebuild loses the subject — exactly the #284 artifact"
8772        );
8773    }
8774
8775    /// bsv-low M19 R2 round 3 (review HIGH-1): a stored BEEF whose SUBJECT
8776    /// sits on a stale (orphan) bump at height H, re-stitched with a
8777    /// same-height DIFFERENT-root proof, must serialize with the subject on
8778    /// the NEW bump and the orphan bump dropped — otherwise the hop leg
8779    /// churns and re-latches the orphan proof forever.
8780    #[test]
8781    fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
8782        use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
8783        // subject spends a real parent; both are raw in the BEEF
8784        let parent = Transaction::from_hex(RAW_PARENT).unwrap();
8785        let parent_id = parent.id();
8786        let subject = child_of(&parent_id, 0);
8787        let subject_id = subject.id();
8788        // the ORPHAN bump: a two-leaf block at H so its root is NOT the txid,
8789        // and it differs from the canonical block's root
8790        let orphan = MerklePath::new_unchecked(
8791            965_771,
8792            vec![vec![
8793                MerklePathLeaf::new_txid(0, subject_id.clone()),
8794                MerklePathLeaf::new(1, "aa".repeat(32)),
8795            ]],
8796        )
8797        .unwrap();
8798        let mut beef = Beef::new();
8799        let bi = beef.merge_bump(orphan.clone());
8800        beef.merge_raw_tx(parent.to_binary(), None);
8801        beef.merge_raw_tx(subject.to_binary(), Some(bi));
8802        let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
8803        let stored = beef.to_binary();
8804        assert_eq!(
8805            Beef::from_binary(&stored)
8806                .unwrap()
8807                .find_bump(&subject_id)
8808                .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
8809            Some(orphan_root.clone())
8810        );
8811        // the canonical proof at the SAME height, a DIFFERENT root
8812        let canonical = MerklePath::new_unchecked(
8813            965_771,
8814            vec![vec![
8815                MerklePathLeaf::new_txid(0, subject_id.clone()),
8816                MerklePathLeaf::new(1, "bb".repeat(32)),
8817            ]],
8818        )
8819        .unwrap();
8820        let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
8821        assert_ne!(
8822            canonical_root, orphan_root,
8823            "the reorg shape: same height, new root"
8824        );
8825        let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
8826        let after = Beef::from_binary(&out).unwrap();
8827        // the subject's OWN bump is the canonical one
8828        let own = after
8829            .find_txid(&subject_id)
8830            .and_then(BeefTx::bump_index)
8831            .and_then(|bi| after.bumps.get(bi))
8832            .unwrap();
8833        assert_eq!(
8834            own.compute_root(Some(&subject_id)).unwrap(),
8835            canonical_root,
8836            "the subject re-anchored to the new bump"
8837        );
8838        // the orphan bump is gone (only the canonical one remains)
8839        assert_eq!(
8840            after.bumps.len(),
8841            1,
8842            "the unreferenced orphan bump was dropped"
8843        );
8844        // and a reader that uses the tx's OWN bump sees canonical, never orphan
8845        assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
8846    }
8847
8848    #[test]
8849    fn stitch_refuses_a_beef_that_lacks_the_txid() {
8850        use bsv_rs::transaction::MerklePath;
8851        let (stored, _c, _p) = subject_not_last_beef();
8852        let foreign = "aa".repeat(32);
8853        let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
8854        assert!(
8855            Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
8856            "no txid in the beef -> no write, never garbage"
8857        );
8858    }
8859
8860    /// A real mainnet raw tx, the unproven parent for the re-anchor pin.
8861    const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
8862
8863    /// A minimal raw tx spending `source:vout` with one OP_TRUE output.
8864    fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
8865        let mut sx = String::from("0100000001");
8866        let mut prev = hex::decode(source_txid).unwrap();
8867        prev.reverse();
8868        sx.push_str(&hex::encode(prev));
8869        sx.push_str(&hex::encode(vout.to_le_bytes()));
8870        sx.push_str("00ffffffff01");
8871        sx.push_str(&hex::encode(1000u64.to_le_bytes()));
8872        sx.push_str("015100000000");
8873        bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
8874    }
8875
8876    /// A minimal valid single-leaf BUMP hex proving `txid` at `height`.
8877    fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
8878        use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
8879        let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
8880        MerklePath::new_unchecked(height, vec![vec![leaf]])
8881            .unwrap()
8882            .to_hex()
8883    }
8884
8885    /// Mock fetcher: returns a configured proof (or none) for any txid, and
8886    /// counts how many times it was asked.
8887    struct StubFetcher {
8888        proof: Option<String>,
8889        calls: Cell<u32>,
8890    }
8891
8892    #[async_trait(?Send)]
8893    impl AncestorFetcher for StubFetcher {
8894        async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
8895            self.calls.set(self.calls.get() + 1);
8896            Ok(FetchedAncestor {
8897                raw_tx: PROOFLESS_RAW_TX.to_string(),
8898                proof: self.proof.clone(),
8899            })
8900        }
8901
8902        /// Models a fetcher whose header source CONFIRMS a stored bump — so the
8903        /// already-structurally-proven re-verify gate (the window-clog path)
8904        /// latches without a re-fetch, exactly as before the #192/#193 hardening.
8905        /// (`verified_proof_for` is left at its default: it delegates to
8906        /// `fetch_ancestor` above, so the `calls` counter still tracks fetches.)
8907        async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
8908            true
8909        }
8910    }
8911
8912    /// Build an engine over a storage holding one proofless output, with an
8913    /// optional ancestor fetcher.
8914    async fn engine_with_proofless_output(
8915        fetcher: Option<std::rc::Rc<StubFetcher>>,
8916    ) -> (Engine, String) {
8917        let (beef, txid) = proofless_beef();
8918        let storage = MemoryStorage::new();
8919        storage
8920            .insert_output(&Output {
8921                txid: txid.clone(),
8922                output_index: 0,
8923                output_script: vec![0x76],
8924                satoshis: 1_000_000_000,
8925                topic: "Hello".to_string(),
8926                spent: false,
8927                outputs_consumed: vec![],
8928                consumed_by: vec![],
8929                beef: Some(beef),
8930                block_height: None,
8931                score: Some(1.0),
8932            })
8933            .await
8934            .unwrap();
8935
8936        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
8937        managers.insert(
8938            "Hello".into(),
8939            Box::new(MockTopicManager::admitting(vec![0])),
8940        );
8941        let mut engine = Engine::new(
8942            managers,
8943            HashMap::new(),
8944            Box::new(storage),
8945            None,
8946            EngineConfig::default(),
8947        );
8948        if let Some(f) = fetcher {
8949            engine.set_ancestor_fetcher(f);
8950        }
8951        (engine, txid)
8952    }
8953
8954    #[tokio::test]
8955    async fn complete_missing_proofs_noop_without_fetcher() {
8956        // No ancestor fetcher configured → pure no-op (production default).
8957        let (engine, _txid) = engine_with_proofless_output(None).await;
8958        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
8959        assert_eq!(summary, ProofCompletionSummary::default());
8960    }
8961
8962    #[tokio::test]
8963    async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
8964        let (_beef, txid) = proofless_beef();
8965        let proof_hex = single_leaf_bump_hex(&txid, 850_000);
8966        let fetcher = std::rc::Rc::new(StubFetcher {
8967            proof: Some(proof_hex),
8968            calls: Cell::new(0),
8969        });
8970        let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
8971
8972        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
8973        assert_eq!(summary.scanned, 1);
8974        assert_eq!(summary.proofless, 1);
8975        assert_eq!(
8976            summary.completed, 1,
8977            "the proofless BEEF should be completed"
8978        );
8979        assert_eq!(fetcher.calls.get(), 1);
8980
8981        // The stored BEEF now carries a proof for the target tx + the output
8982        // got its block height.
8983        let out = engine
8984            .storage()
8985            .find_output(&txid, 0, Some("Hello"), None, true)
8986            .await
8987            .unwrap()
8988            .unwrap();
8989        assert_eq!(out.block_height, Some(850_000));
8990        let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
8991        assert!(
8992            beef.find_txid(&txid)
8993                .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
8994            "completed BEEF must prove the target tx"
8995        );
8996
8997        // A second pass is a no-op: the tx is now proven, so it is not counted
8998        // proofless and the fetcher is not asked again.
8999        let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
9000        assert_eq!(summary2.proofless, 0);
9001        assert_eq!(summary2.completed, 0);
9002        assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
9003    }
9004
9005    #[tokio::test]
9006    async fn complete_missing_proofs_skips_unconfirmed() {
9007        // Fetcher reachable but the tx is unmined (no BUMP) → skipped, not
9008        // errored, and counted still_unconfirmed for retry.
9009        let fetcher = std::rc::Rc::new(StubFetcher {
9010            proof: None,
9011            calls: Cell::new(0),
9012        });
9013        let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
9014        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
9015        assert_eq!(summary.proofless, 1);
9016        assert_eq!(summary.completed, 0);
9017        assert_eq!(summary.still_unconfirmed, 1);
9018        assert_eq!(fetcher.calls.get(), 1);
9019    }
9020
9021    /// Build an engine like [`engine_with_proofless_output`] but keep a shared
9022    /// handle to the `MemoryStorage` (via the `Rc<T: Storage>` delegation) so
9023    /// the test can advance its deterministic clock — the push-primary
9024    /// backstop age-gate tests (#228) need to age rows without sleeping.
9025    async fn engine_with_proofless_output_shared(
9026        fetcher: Option<std::rc::Rc<StubFetcher>>,
9027    ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
9028        let (beef, txid) = proofless_beef();
9029        let storage = std::rc::Rc::new(MemoryStorage::new());
9030        storage
9031            .insert_output(&Output {
9032                txid: txid.clone(),
9033                output_index: 0,
9034                output_script: vec![0x76],
9035                satoshis: 1_000_000_000,
9036                topic: "Hello".to_string(),
9037                spent: false,
9038                outputs_consumed: vec![],
9039                consumed_by: vec![],
9040                beef: Some(beef),
9041                block_height: None,
9042                score: Some(1.0),
9043            })
9044            .await
9045            .unwrap();
9046
9047        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9048        managers.insert(
9049            "Hello".into(),
9050            Box::new(MockTopicManager::admitting(vec![0])),
9051        );
9052        let mut engine = Engine::new(
9053            managers,
9054            HashMap::new(),
9055            Box::new(storage.clone()),
9056            None,
9057            EngineConfig::default(),
9058        );
9059        if let Some(f) = fetcher {
9060            engine.set_ancestor_fetcher(f);
9061        }
9062        (engine, txid, storage)
9063    }
9064
9065    #[tokio::test]
9066    async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
9067        // Push-primary backstop (#228): a FRESHLY stored proofless tx must NOT
9068        // be polled — its proof is expected via /arc-ingest at push speed. The
9069        // gated pass scans nothing and never asks the fetcher.
9070        let proof_hex = {
9071            let (_beef, txid) = proofless_beef();
9072            single_leaf_bump_hex(&txid, 850_000)
9073        };
9074        let fetcher = std::rc::Rc::new(StubFetcher {
9075            proof: Some(proof_hex),
9076            calls: Cell::new(0),
9077        });
9078        let (engine, _txid, storage) =
9079            engine_with_proofless_output_shared(Some(fetcher.clone())).await;
9080
9081        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
9082        assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
9083        assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
9084
9085        // WEBHOOK-OUTAGE DEGRADATION: no push ever arrives. Once the row is
9086        // older than the gate, the SAME backstop pass polls and completes it
9087        // exactly as the pre-#228 behaviour — degradation is to polling,
9088        // never to nothing.
9089        storage.advance_clock(1800);
9090        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
9091        assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
9092        assert_eq!(summary.completed, 1, "the backstop completes the proof");
9093        assert_eq!(fetcher.calls.get(), 1);
9094    }
9095
9096    #[tokio::test]
9097    async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
9098        // Pushed-proof-then-chaser-skips (#228): a proof arriving via the real
9099        // /arc-ingest producer path (`handle_new_merkle_proof` → the
9100        // `update_transaction_beef` stitch, which latches `has_proof`) drops
9101        // the tx out of the poll candidate set entirely — even after the age
9102        // gate opens, the chaser never asks the fetcher about it.
9103        let fetcher = std::rc::Rc::new(StubFetcher {
9104            proof: None,
9105            calls: Cell::new(0),
9106        });
9107        let (engine, txid, storage) =
9108            engine_with_proofless_output_shared(Some(fetcher.clone())).await;
9109
9110        // The push lands (arc-ingest calls exactly this after its own
9111        // chaintracks verify).
9112        let proof_hex = single_leaf_bump_hex(&txid, 850_000);
9113        engine
9114            .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
9115            .await
9116            .unwrap();
9117
9118        // Age the row well past the backstop gate: still skipped — the pushed
9119        // proof, not the age, is what removes it from the chaser's world.
9120        storage.advance_clock(1_000_000);
9121        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
9122        assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
9123        assert_eq!(summary.completed, 0);
9124        assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
9125    }
9126
9127    #[tokio::test]
9128    async fn complete_missing_proofs_respects_limit() {
9129        // Two proofless txs, limit 1 → only one row is scanned this tick.
9130        let (beef1, txid1) = proofless_beef();
9131        // A second distinct proofless tx (different raw tx → different txid).
9132        let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
9133        let (beef2, txid2) = {
9134            use bsv_rs::transaction::{Beef, Transaction};
9135            let tx = Transaction::from_hex(raw2).unwrap();
9136            let id = tx.id();
9137            let mut b = Beef::new();
9138            b.merge_transaction(tx);
9139            (b.to_binary(), id)
9140        };
9141
9142        let storage = MemoryStorage::new();
9143        for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
9144            storage
9145                .insert_output(&Output {
9146                    txid: txid.clone(),
9147                    output_index: 0,
9148                    output_script: vec![0x76],
9149                    satoshis: 1000,
9150                    topic: "Hello".to_string(),
9151                    spent: false,
9152                    outputs_consumed: vec![],
9153                    consumed_by: vec![],
9154                    beef: Some(beef),
9155                    block_height: None,
9156                    score: Some(1.0),
9157                })
9158                .await
9159                .unwrap();
9160        }
9161
9162        let fetcher = std::rc::Rc::new(StubFetcher {
9163            proof: None,
9164            calls: Cell::new(0),
9165        });
9166        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9167        managers.insert(
9168            "Hello".into(),
9169            Box::new(MockTopicManager::admitting(vec![0])),
9170        );
9171        let mut engine = Engine::new(
9172            managers,
9173            HashMap::new(),
9174            Box::new(storage),
9175            None,
9176            EngineConfig::default(),
9177        );
9178        engine.set_ancestor_fetcher(fetcher.clone());
9179
9180        let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
9181        assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
9182    }
9183
9184    #[tokio::test]
9185    async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
9186        // Window-clog regression (#130): migration 0010 defaulted EVERY
9187        // existing `transactions` row to has_proof = 0, including rows whose
9188        // stored BEEF already carries a proof (e.g. GASP-synced txs). The
9189        // candidate query (`WHERE has_proof = 0 LIMIT n`) returns those rows
9190        // every tick; the engine skips them but — before this fix — never
9191        // flipped the flag, so they lingered in the bounded window forever and
9192        // starved genuinely-proofless rows behind them. The fix marks each
9193        // already-proven scanned row so it drops out of the NEXT pass.
9194        use bsv_rs::transaction::{Beef, MerklePath, Transaction};
9195
9196        let storage = MemoryStorage::new();
9197
9198        // Seed several ALREADY-PROVEN rows. They are inserted via
9199        // `insert_output`, which (modeling migration 0010's default) does NOT
9200        // set the proof flag — so despite carrying a valid proof they show up
9201        // as has_proof = 0 candidates: the exact clog.
9202        let proven_raws = [
9203            "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
9204            "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
9205        ];
9206        let mut proven_ids = Vec::new();
9207        for raw in proven_raws {
9208            let mut tx = Transaction::from_hex(raw).unwrap();
9209            let id = tx.id();
9210            tx.merkle_path =
9211                Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
9212            // to_beef(true) carries the merkle proof into the BEEF, so the row
9213            // genuinely IS proven — yet its flag stays 0 (insert path).
9214            let proven_beef = tx.to_beef(true).unwrap();
9215            assert_eq!(
9216                Beef::from_binary(&proven_beef)
9217                    .unwrap()
9218                    .find_txid(&id)
9219                    .map(bsv_rs::transaction::BeefTx::has_proof),
9220                Some(true),
9221                "proven fixture row must carry its proof"
9222            );
9223            proven_ids.push(id.clone());
9224            storage
9225                .insert_output(&Output {
9226                    txid: id,
9227                    output_index: 0,
9228                    output_script: vec![0x76],
9229                    satoshis: 1000,
9230                    topic: "Hello".to_string(),
9231                    spent: false,
9232                    outputs_consumed: vec![],
9233                    consumed_by: vec![],
9234                    beef: Some(proven_beef),
9235                    block_height: Some(800_000),
9236                    score: Some(1.0),
9237                })
9238                .await
9239                .unwrap();
9240        }
9241
9242        let fetcher = std::rc::Rc::new(StubFetcher {
9243            proof: None,
9244            calls: Cell::new(0),
9245        });
9246        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9247        managers.insert(
9248            "Hello".into(),
9249            Box::new(MockTopicManager::admitting(vec![0])),
9250        );
9251        let mut engine = Engine::new(
9252            managers,
9253            HashMap::new(),
9254            Box::new(storage),
9255            None,
9256            EngineConfig::default(),
9257        );
9258        engine.set_ancestor_fetcher(fetcher.clone());
9259
9260        // First pass over a window covering both proven rows: each is detected
9261        // already-proven and marked. Nothing proofless, nothing fetched.
9262        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
9263        assert_eq!(summary.scanned, proven_ids.len());
9264        assert_eq!(summary.proofless, 0);
9265        assert_eq!(summary.completed, 0);
9266        assert_eq!(
9267            summary.already_proven,
9268            proven_ids.len(),
9269            "every already-proven row is marked this pass"
9270        );
9271        assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
9272
9273        // Window has advanced: the NEXT candidate query no longer returns the
9274        // marked rows, so the pass scans nothing.
9275        let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
9276        assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
9277        assert_eq!(summary2.already_proven, 0);
9278    }
9279
9280    #[tokio::test]
9281    async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
9282        // The window-clog in action with a small limit: a genuinely-proofless
9283        // backlog row sits behind already-proven (stale-flag) rows. Each tick
9284        // marks the proven rows it scans (dropping them out), so over a few
9285        // bounded ticks the window advances until the proofless tx is reached
9286        // + completed — instead of being starved forever.
9287        use bsv_rs::transaction::{MerklePath, Transaction};
9288
9289        let storage = MemoryStorage::new();
9290
9291        let proven_raws = [
9292            "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
9293        ];
9294        for raw in proven_raws {
9295            let mut tx = Transaction::from_hex(raw).unwrap();
9296            let id = tx.id();
9297            tx.merkle_path =
9298                Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
9299            let proven_beef = tx.to_beef(true).unwrap();
9300            storage
9301                .insert_output(&Output {
9302                    txid: id,
9303                    output_index: 0,
9304                    output_script: vec![0x76],
9305                    satoshis: 1000,
9306                    topic: "Hello".to_string(),
9307                    spent: false,
9308                    outputs_consumed: vec![],
9309                    consumed_by: vec![],
9310                    beef: Some(proven_beef),
9311                    block_height: Some(800_000),
9312                    score: Some(1.0),
9313                })
9314                .await
9315                .unwrap();
9316        }
9317
9318        // The historical proofless tx (the f584f846-style backlog row).
9319        let (proofless, proofless_txid) = proofless_beef();
9320        storage
9321            .insert_output(&Output {
9322                txid: proofless_txid.clone(),
9323                output_index: 0,
9324                output_script: vec![0x76],
9325                satoshis: 1000,
9326                topic: "Hello".to_string(),
9327                spent: false,
9328                outputs_consumed: vec![],
9329                consumed_by: vec![],
9330                beef: Some(proofless),
9331                block_height: None,
9332                score: Some(1.0),
9333            })
9334            .await
9335            .unwrap();
9336
9337        let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
9338        let fetcher = std::rc::Rc::new(StubFetcher {
9339            proof: Some(proof_hex),
9340            calls: Cell::new(0),
9341        });
9342        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9343        managers.insert(
9344            "Hello".into(),
9345            Box::new(MockTopicManager::admitting(vec![0])),
9346        );
9347        let mut engine = Engine::new(
9348            managers,
9349            HashMap::new(),
9350            Box::new(storage),
9351            None,
9352            EngineConfig::default(),
9353        );
9354        engine.set_ancestor_fetcher(fetcher.clone());
9355
9356        // Run bounded ticks (limit 1) until the proofless tx is completed. With
9357        // 2 rows total a couple of ticks suffice; cap iterations defensively.
9358        let mut completed_total = 0;
9359        for _ in 0..5 {
9360            let s = engine.complete_missing_proofs(1, 0).await.unwrap();
9361            completed_total += s.completed;
9362            if completed_total > 0 {
9363                break;
9364            }
9365        }
9366        assert_eq!(
9367            completed_total, 1,
9368            "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
9369        );
9370
9371        let out = engine
9372            .storage()
9373            .find_output(&proofless_txid, 0, Some("Hello"), None, true)
9374            .await
9375            .unwrap()
9376            .unwrap();
9377        let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
9378        assert_eq!(
9379            beef.find_txid(&proofless_txid)
9380                .map(bsv_rs::transaction::BeefTx::has_proof),
9381            Some(true),
9382            "the old proofless tx now carries its proof"
9383        );
9384    }
9385
9386    // ── S2 queue-durable admission (bsv-low 2026-08-29): Phase-3 faults ──
9387    //
9388    // The phantom class: under a D1 storm `submit` returned Ok(steak) while
9389    // its writes failed — the ack outlived the admission. These pins hold
9390    // the new contract: a fault is REPORTED, the faulted topic is NOT
9391    // recorded as applied, and a replay of the same bytes re-applies.
9392
9393    use crate::storage::StorageError;
9394    use std::rc::Rc;
9395
9396    /// `MemoryStorage` behind two fault switches. Every method delegates;
9397    /// the two the tests flip model a D1 write fault (`insert_output`) and
9398    /// a D1 read fault (`find_output` — the validation's previous-coin scan
9399    /// AND Phase 3's stale/consumed lookups).
9400    struct FaultingStorage {
9401        inner: MemoryStorage,
9402        fail_insert_output: Cell<bool>,
9403        fail_find_output: Cell<bool>,
9404    }
9405
9406    impl FaultingStorage {
9407        fn new() -> Rc<Self> {
9408            Rc::new(Self {
9409                inner: MemoryStorage::new(),
9410                fail_insert_output: Cell::new(false),
9411                fail_find_output: Cell::new(false),
9412            })
9413        }
9414    }
9415
9416    #[async_trait(?Send)]
9417    impl Storage for FaultingStorage {
9418        async fn put_deferred_graph(
9419            &self,
9420            record: &crate::gasp::DeferredGraph,
9421        ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
9422            self.inner.put_deferred_graph(record).await
9423        }
9424        async fn find_deferred_graphs(
9425            &self,
9426            host: &str,
9427            topic: &str,
9428        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
9429            self.inner.find_deferred_graphs(host, topic).await
9430        }
9431        async fn get_deferred_graph(
9432            &self,
9433            host: &str,
9434            topic: &str,
9435            outpoint: &str,
9436        ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
9437            self.inner.get_deferred_graph(host, topic, outpoint).await
9438        }
9439        async fn delete_deferred_graph(
9440            &self,
9441            host: &str,
9442            topic: &str,
9443            outpoint: &str,
9444        ) -> Result<(), StorageError> {
9445            self.inner
9446                .delete_deferred_graph(host, topic, outpoint)
9447                .await
9448        }
9449        async fn record_peer_sync_outcome(
9450            &self,
9451            host: &str,
9452            topic: &str,
9453            success: bool,
9454        ) -> Result<(), StorageError> {
9455            self.inner
9456                .record_peer_sync_outcome(host, topic, success)
9457                .await
9458        }
9459        async fn get_peer_sync_health(
9460            &self,
9461            host: &str,
9462            topic: &str,
9463        ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
9464            self.inner.get_peer_sync_health(host, topic).await
9465        }
9466        async fn record_peer_sync_yield(
9467            &self,
9468            host: &str,
9469            topic: &str,
9470            yielded: bool,
9471        ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
9472            self.inner
9473                .record_peer_sync_yield(host, topic, yielded)
9474                .await
9475        }
9476        async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
9477            if self.fail_insert_output.get() {
9478                return Err(StorageError::Database(
9479                    "D1_ERROR: storage overloaded".into(),
9480                ));
9481            }
9482            self.inner.insert_output(output).await
9483        }
9484        async fn delete_output(
9485            &self,
9486            txid: &str,
9487            output_index: u32,
9488            topic: &str,
9489        ) -> Result<(), StorageError> {
9490            self.inner.delete_output(txid, output_index, topic).await
9491        }
9492        async fn mark_utxo_as_spent(
9493            &self,
9494            txid: &str,
9495            output_index: u32,
9496            topic: &str,
9497        ) -> Result<(), StorageError> {
9498            self.inner
9499                .mark_utxo_as_spent(txid, output_index, topic)
9500                .await
9501        }
9502        async fn update_consumed_by(
9503            &self,
9504            txid: &str,
9505            output_index: u32,
9506            topic: &str,
9507            consumed_by: &[Outpoint],
9508        ) -> Result<(), StorageError> {
9509            self.inner
9510                .update_consumed_by(txid, output_index, topic, consumed_by)
9511                .await
9512        }
9513        async fn update_transaction_beef(
9514            &self,
9515            txid: &str,
9516            beef: &[u8],
9517        ) -> Result<(), StorageError> {
9518            self.inner.update_transaction_beef(txid, beef).await
9519        }
9520        async fn insert_applied_transaction(
9521            &self,
9522            tx: &AppliedTransaction,
9523        ) -> Result<(), StorageError> {
9524            self.inner.insert_applied_transaction(tx).await
9525        }
9526        async fn does_applied_transaction_exist(
9527            &self,
9528            tx: &AppliedTransaction,
9529        ) -> Result<bool, StorageError> {
9530            self.inner.does_applied_transaction_exist(tx).await
9531        }
9532        async fn delete_applied_transaction(
9533            &self,
9534            tx: &AppliedTransaction,
9535        ) -> Result<(), StorageError> {
9536            self.inner.delete_applied_transaction(tx).await
9537        }
9538        async fn find_output(
9539            &self,
9540            txid: &str,
9541            output_index: u32,
9542            topic: Option<&str>,
9543            spent: Option<bool>,
9544            include_beef: bool,
9545        ) -> Result<Option<Output>, StorageError> {
9546            if self.fail_find_output.get() {
9547                return Err(StorageError::Database("D1_ERROR: read timed out".into()));
9548            }
9549            self.inner
9550                .find_output(txid, output_index, topic, spent, include_beef)
9551                .await
9552        }
9553        async fn find_outputs_for_transaction(
9554            &self,
9555            txid: &str,
9556            include_beef: bool,
9557        ) -> Result<Vec<Output>, StorageError> {
9558            self.inner
9559                .find_outputs_for_transaction(txid, include_beef)
9560                .await
9561        }
9562        async fn find_utxos_for_topic(
9563            &self,
9564            topic: &str,
9565            since: Option<f64>,
9566            limit: Option<u64>,
9567            include_beef: bool,
9568        ) -> Result<Vec<Output>, StorageError> {
9569            self.inner
9570                .find_utxos_for_topic(topic, since, limit, include_beef)
9571                .await
9572        }
9573        async fn update_last_interaction(
9574            &self,
9575            host: &str,
9576            topic: &str,
9577            since: u64,
9578        ) -> Result<(), StorageError> {
9579            self.inner.update_last_interaction(host, topic, since).await
9580        }
9581        async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
9582            self.inner.get_last_interaction(host, topic).await
9583        }
9584    }
9585
9586    fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
9587        let storage = FaultingStorage::new();
9588        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9589        managers.insert(
9590            "tm_test".to_string(),
9591            Box::new(MockTopicManager::admitting(admit_indices)),
9592        );
9593        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
9594        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
9595        let engine = Engine::new(
9596            managers,
9597            lookup_services,
9598            Box::new(Rc::clone(&storage)),
9599            None,
9600            EngineConfig::default(),
9601        );
9602        (engine, storage)
9603    }
9604
9605    async fn applied(storage: &FaultingStorage) -> bool {
9606        storage
9607            .does_applied_transaction_exist(&AppliedTransaction {
9608                txid: TEST_TXID.to_string(),
9609                topic: "tm_test".to_string(),
9610            })
9611            .await
9612            .unwrap()
9613    }
9614
9615    /// The old behaviour, pinned: a clean submit is durable, records the
9616    /// topic as applied, and the output is in storage.
9617    #[tokio::test]
9618    async fn clean_submit_is_durable_and_records_applied() {
9619        let (engine, storage) = make_faulting_engine(vec![0]);
9620        let (steak, report) = engine
9621            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9622            .await
9623            .unwrap();
9624        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
9625        assert!(report.is_durable(), "{}", report.summary());
9626        assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
9627        assert!(applied(&storage).await);
9628        assert!(storage
9629            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
9630            .await
9631            .unwrap()
9632            .is_some());
9633    }
9634
9635    /// THE PHANTOM, red→green: the insert fails, the steak still says
9636    /// "admitted" (the decision stands), but the report says NOT DURABLE and
9637    /// names the site — and the topic is NOT recorded as applied, so nothing
9638    /// downstream can treat the loss as final.
9639    #[tokio::test]
9640    async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
9641        let (engine, storage) = make_faulting_engine(vec![0]);
9642        storage.fail_insert_output.set(true);
9643        let (steak, report) = engine
9644            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9645            .await
9646            .unwrap();
9647        assert_eq!(
9648            steak["tm_test"].outputs_to_admit,
9649            vec![0],
9650            "the admission DECISION is unchanged by a write fault"
9651        );
9652        assert!(!report.is_durable());
9653        assert_eq!(report.faults.len(), 1, "{}", report.summary());
9654        assert_eq!(report.faults[0].topic, "tm_test");
9655        assert_eq!(report.faults[0].site, "insert_output");
9656        assert!(report.faults[0].error.contains("D1_ERROR"));
9657        assert!(report.applied_topics.is_empty());
9658        assert!(
9659            !applied(&storage).await,
9660            "a faulted topic must not be recorded as applied — the replay would be a dupe"
9661        );
9662        assert!(storage
9663            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
9664            .await
9665            .unwrap()
9666            .is_none());
9667    }
9668
9669    /// The replay: same bytes, storage healthy again → re-validated (not a
9670    /// dupe), re-written, recorded. A THIRD submit is then the dupe it
9671    /// should be: nothing admitted, nothing to write, durable.
9672    #[tokio::test]
9673    async fn replay_after_a_fault_reapplies_and_records_applied() {
9674        let (engine, storage) = make_faulting_engine(vec![0]);
9675        storage.fail_insert_output.set(true);
9676        let (_, first) = engine
9677            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9678            .await
9679            .unwrap();
9680        assert!(!first.is_durable());
9681
9682        storage.fail_insert_output.set(false);
9683        let (steak, replay) = engine
9684            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9685            .await
9686            .unwrap();
9687        assert_eq!(
9688            steak["tm_test"].outputs_to_admit,
9689            vec![0],
9690            "re-validated, not deduped"
9691        );
9692        assert!(replay.is_durable(), "{}", replay.summary());
9693        assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
9694        assert!(applied(&storage).await);
9695        assert!(storage
9696            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
9697            .await
9698            .unwrap()
9699            .is_some());
9700
9701        let (steak, third) = engine
9702            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9703            .await
9704            .unwrap();
9705        assert!(
9706            steak["tm_test"].outputs_to_admit.is_empty(),
9707            "now a genuine dupe: nothing admitted"
9708        );
9709        assert!(third.is_durable() && third.applied_topics.is_empty());
9710    }
9711
9712    /// The SPEND half of the phantom: a `find_output` fault during
9713    /// validation made a settle look like it consumed nothing, so the spend
9714    /// pointer was never written and the ack stood. Now the read fault is
9715    /// reported, the topic stays unrecorded, the previously-admitted coin
9716    /// is untouched — and the healthy replay consumes it.
9717    #[tokio::test]
9718    async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
9719        let (engine, storage) = make_faulting_engine(vec![0]);
9720        // The subject's own input: seed the coin it spends as a previously
9721        // admitted tm_test output.
9722        let tx = Transaction::from_beef(&test_beef(), None).unwrap();
9723        let source_txid = tx.inputs[0].get_source_txid().unwrap();
9724        let source_vout = tx.inputs[0].source_output_index;
9725        storage
9726            .insert_output(&Output {
9727                txid: source_txid.clone(),
9728                output_index: source_vout,
9729                output_script: vec![0x51],
9730                satoshis: 1,
9731                topic: "tm_test".to_string(),
9732                spent: false,
9733                outputs_consumed: vec![],
9734                consumed_by: vec![],
9735                beef: None,
9736                block_height: None,
9737                score: Some(1.0),
9738            })
9739            .await
9740            .unwrap();
9741
9742        storage.fail_find_output.set(true);
9743        let (_, faulted) = engine
9744            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9745            .await
9746            .unwrap();
9747        assert!(!faulted.is_durable());
9748        assert!(
9749            faulted.faults.iter().any(|f| f.site == "find_output"),
9750            "{}",
9751            faulted.summary()
9752        );
9753        assert!(
9754            !applied(&storage).await,
9755            "unrecorded: the replay must re-read"
9756        );
9757        storage.fail_find_output.set(false);
9758        assert!(
9759            storage
9760                .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
9761                .await
9762                .unwrap()
9763                .is_some(),
9764            "the coin the faulted pass could not see is still there, untouched"
9765        );
9766
9767        let (_, replay) = engine
9768            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9769            .await
9770            .unwrap();
9771        assert!(replay.is_durable(), "{}", replay.summary());
9772        assert!(applied(&storage).await);
9773        // The mock manager retains no coins, so the consumed coin is stale
9774        // and deleted — the observable proof the spend was PROCESSED.
9775        assert!(
9776            storage
9777                .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
9778                .await
9779                .unwrap()
9780                .is_none(),
9781            "the healthy replay consumed the coin the faulted pass missed"
9782        );
9783    }
9784
9785    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): a lookup service that missed
9786    /// its admit notification is told AGAIN from the engine's own stored
9787    /// outputs — one call per admitted output per service, the WholeTx body
9788    /// naming the subject — while `/submit` of the same bytes is deduped and
9789    /// tells nobody (the premise, RED-verified in the same test); a topic the
9790    /// engine never admitted this txid on yields 0 (the caller re-submits).
9791    #[tokio::test]
9792    async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
9793        use std::cell::RefCell;
9794        use std::rc::Rc;
9795
9796        struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
9797        #[async_trait(?Send)]
9798        impl LookupServiceTrait for CountingLs {
9799            fn admission_mode(&self) -> AdmissionMode {
9800                self.1
9801            }
9802            fn spend_notification_mode(&self) -> SpendNotificationMode {
9803                SpendNotificationMode::None
9804            }
9805            async fn output_admitted_by_topic(
9806                &self,
9807                payload: &OutputAdmittedByTopic,
9808            ) -> Result<(), LookupServiceError> {
9809                let rec = match payload {
9810                    OutputAdmittedByTopic::LockingScript {
9811                        txid,
9812                        output_index,
9813                        topic,
9814                        ..
9815                    } => (txid.clone(), *output_index, topic.clone()),
9816                    OutputAdmittedByTopic::WholeTx {
9817                        atomic_beef,
9818                        output_index,
9819                        topic,
9820                        ..
9821                    } => {
9822                        let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
9823                        (
9824                            beef.atomic_txid
9825                                .clone()
9826                                .expect("the WholeTx body NAMES its subject"),
9827                            *output_index,
9828                            topic.clone(),
9829                        )
9830                    }
9831                };
9832                self.0.borrow_mut().push(rec);
9833                Ok(())
9834            }
9835            async fn output_evicted(
9836                &self,
9837                _txid: &str,
9838                _output_index: u32,
9839            ) -> Result<(), LookupServiceError> {
9840                Ok(())
9841            }
9842            async fn lookup(
9843                &self,
9844                _question: &LookupQuestion,
9845            ) -> Result<LookupResult, LookupServiceError> {
9846                Ok(LookupResult::OutputList(Vec::new()))
9847            }
9848            async fn get_documentation(&self) -> String {
9849                "counting".to_string()
9850            }
9851            async fn get_metadata(&self) -> ServiceMetadata {
9852                ServiceMetadata {
9853                    name: "counting-ls".to_string(),
9854                    ..Default::default()
9855                }
9856            }
9857        }
9858
9859        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9860        managers.insert(
9861            "tm_test".to_string(),
9862            Box::new(MockTopicManager::admitting(vec![0])),
9863        );
9864        let seen = Rc::new(RefCell::new(Vec::new()));
9865        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
9866        lookup_services.insert(
9867            "ls_whole".to_string(),
9868            Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
9869        );
9870        lookup_services.insert(
9871            "ls_script".to_string(),
9872            Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
9873        );
9874        let engine = Engine::new(
9875            managers,
9876            lookup_services,
9877            Box::new(MemoryStorage::new()),
9878            None,
9879            EngineConfig::default(),
9880        );
9881
9882        let tagged = test_tagged_beef(vec!["tm_test"]);
9883        engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
9884        assert_eq!(seen.borrow().len(), 2, "one admit per service");
9885        let (txid, vout, _) = seen.borrow()[0].clone();
9886        assert_eq!(txid, TEST_TXID);
9887
9888        // the premise, RED-verified: the same bytes again are DEDUPED — /submit can never re-notify
9889        let (_, report) = engine
9890            .submit_with_report(&tagged, SubmitMode::CurrentTx)
9891            .await
9892            .unwrap();
9893        assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
9894        assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
9895
9896        // the verb: told again from the stored outputs, the WholeTx body naming the subject
9897        let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
9898        assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
9899        assert_eq!(r.vouts, vec![vout]);
9900        assert_eq!(seen.borrow().len(), 4);
9901        assert!(seen.borrow()[2..]
9902            .iter()
9903            .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
9904
9905        // a topic the engine never admitted this txid on: nothing to re-notify (re-submit instead)
9906        let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
9907        assert_eq!((r0.outputs, r0.notified), (0, 0));
9908        assert_eq!(seen.borrow().len(), 4);
9909
9910        // idempotent: a second re-notify tells everyone again, faults nothing
9911        let r2 = engine
9912            .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
9913            .await
9914            .unwrap();
9915        assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
9916        assert_eq!(seen.borrow().len(), 6);
9917    }
9918
9919    /// bsv-low PLAN-PRE-LOOP4 §H4: a PHANTOM applied row (the topic admitted
9920    /// NOTHING) dedups every re-submit of those bytes; forgetting it lets the
9921    /// same bytes be judged again — while a REAL admission's row is never
9922    /// forgotten and a missing row is nothing to forget.
9923    #[tokio::test]
9924    async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
9925        // phantom: a TM that admits nothing still records the topic as applied
9926        let engine = make_engine(vec![]);
9927        let tagged = test_tagged_beef(vec!["tm_test"]);
9928        let (steak, r1) = engine
9929            .submit_with_report(&tagged, SubmitMode::CurrentTx)
9930            .await
9931            .unwrap();
9932        assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
9933        assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
9934        let (_, r2) = engine
9935            .submit_with_report(&tagged, SubmitMode::CurrentTx)
9936            .await
9937            .unwrap();
9938        assert_eq!(
9939            r2.deduped_topics,
9940            vec!["tm_test".to_string()],
9941            "the phantom row dedups the re-submit"
9942        );
9943        assert!(engine
9944            .forget_phantom_applied(TEST_TXID, "tm_test")
9945            .await
9946            .unwrap());
9947        let (_, r3) = engine
9948            .submit_with_report(&tagged, SubmitMode::CurrentTx)
9949            .await
9950            .unwrap();
9951        assert!(
9952            r3.deduped_topics.is_empty(),
9953            "judged again after the forget"
9954        );
9955        assert!(
9956            !engine
9957                .forget_phantom_applied(TEST_TXID, "tm_other")
9958                .await
9959                .unwrap(),
9960            "no row: nothing to forget"
9961        );
9962
9963        // real: a TM that admits output 0 — its row is a real admission, never forgotten
9964        let real = make_engine(vec![0]);
9965        real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
9966        assert!(!real
9967            .forget_phantom_applied(TEST_TXID, "tm_test")
9968            .await
9969            .unwrap());
9970        let (_, r4) = real
9971            .submit_with_report(&tagged, SubmitMode::CurrentTx)
9972            .await
9973            .unwrap();
9974        assert_eq!(
9975            r4.deduped_topics,
9976            vec!["tm_test".to_string()],
9977            "a real admission still dedups"
9978        );
9979    }
9980
9981    // ── NL-6e: the SPV walk is linear in a BUMP's leaves (NL-8 W1) ─────
9982
9983    /// Named and generous (a debug build on a loaded machine): the walk at
9984    /// bsv-rs 0.4.0 took 30 to 36 s at 4,096 leaves in release and gave no
9985    /// answer in 120 s at 8,192 (NL-8 W1).
9986    const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
9987
9988    /// The wide BUMP's height (NL-8's `wide k` row).
9989    const WIDE_HEIGHT: u32 = 800_001;
9990
9991    /// `2^k` txids, display hex: the double SHA-256 of `i` (NL-8's probe).
9992    fn wide_leaves(k: u32) -> Vec<String> {
9993        (0..1u32 << k)
9994            .map(|i| {
9995                let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
9996                id.reverse();
9997                hex::encode(id)
9998            })
9999            .collect()
10000    }
10001
10002    /// One BUMP at [`WIDE_HEIGHT`] whose level 0 carries `txids` as hashed
10003    /// leaves and whose `k - 1` upper levels are empty: every node above
10004    /// level 0 is computed from it. Its root, by one walk.
10005    fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
10006        let k = txids.len().trailing_zeros() as usize;
10007        let mut levels = vec![Vec::new(); k];
10008        levels[0] = txids
10009            .iter()
10010            .enumerate()
10011            .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
10012            .collect();
10013        let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
10014        let root = bump.compute_root(None).unwrap();
10015        (bump, root)
10016    }
10017
10018    /// NL-8 W1's bytes: a V1 BEEF of the one wide BUMP and no transaction
10019    /// (`wide_14` at `2^k` leaves, byte for byte the runner's probe).
10020    fn w1_bytes(k: u32) -> (Vec<u8>, String) {
10021        let (bump, root) = wide_bump(&wide_leaves(k));
10022        let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
10023        beef.extend_from_slice(&bump.to_binary());
10024        beef.push(0x00);
10025        (beef, root)
10026    }
10027
10028    /// The entry, [`verify_spv_like_the_reference`], on its own thread under
10029    /// a tracker that carries `root` at [`WIDE_HEIGHT`]: its answer and its
10030    /// time, or a panic naming the bound.
10031    fn walk_within_bound(
10032        bytes: Vec<u8>,
10033        root: String,
10034        verify_scripts: bool,
10035        subject: String,
10036        what: &str,
10037    ) -> (Result<(), String>, std::time::Duration) {
10038        let (tx, rx) = std::sync::mpsc::channel();
10039        std::thread::spawn(move || {
10040            let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
10041            tracker.add_root(WIDE_HEIGHT, root);
10042            let runtime = tokio::runtime::Builder::new_current_thread()
10043                .build()
10044                .unwrap();
10045            let started = std::time::Instant::now();
10046            let verdict = runtime.block_on(verify_spv_like_the_reference(
10047                Some(&tracker),
10048                verify_scripts,
10049                &bytes,
10050                &subject,
10051                DoorBudget::DEFAULT,
10052            ));
10053            let verdict = match verdict {
10054                Ok(None) => Ok(()),
10055                Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
10056                Err(e) => Err(e.to_string()),
10057            };
10058            let _ = tx.send((verdict, started.elapsed()));
10059        });
10060        rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
10061            panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
10062        })
10063    }
10064
10065    /// The escape hatch's structural walk (`verify_scripts` false) on NL-8
10066    /// W1's bytes, 4,096 and 8,192 leaves: accepted, the one root asked of
10067    /// the tracker, well inside the bound.
10068    #[test]
10069    fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
10070        for k in [12u32, 13] {
10071            let (bytes, root) = w1_bytes(k);
10072            let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
10073            let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
10074            println!(
10075                "NL-6e W1 {what}: {verdict:?} in {:.3} s",
10076                took.as_secs_f64()
10077            );
10078            assert_eq!(verdict, Ok(()), "{what}");
10079        }
10080    }
10081
10082    /// The default walk (scripts run, `verify_scripts` true) over `2^k`
10083    /// proven parents in the one wide BUMP, each spent by a binary tree of
10084    /// two-input transactions whose root is the subject: every parent's
10085    /// root is the BUMP's, asked of the tracker, the inputs `2^(k+1) - 2`.
10086    fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
10087        use bsv_rs::script::{LockingScript, UnlockingScript};
10088        use bsv_rs::transaction::{TransactionInput, TransactionOutput};
10089        let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
10090        let spending = |txid: &str| {
10091            let mut input = TransactionInput::new(txid.to_string(), 0);
10092            input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
10093            input
10094        };
10095        let parents: Vec<Transaction> = (0..1u32 << k)
10096            .map(|i| {
10097                let mut tx = Transaction::new();
10098                // A coinbase's null outpoint, a distinct index each.
10099                let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
10100                input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
10101                tx.inputs.push(input);
10102                tx.outputs.push(TransactionOutput::new(1, op_true()));
10103                tx
10104            })
10105            .collect();
10106        let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
10107        let (bump, root) = wide_bump(&ids);
10108        let mut beef = Beef::new();
10109        let at = beef.merge_bump(bump);
10110        for parent in &parents {
10111            beef.merge_raw_tx(parent.to_binary(), Some(at));
10112        }
10113        let mut layer = ids;
10114        while layer.len() > 1 {
10115            layer = layer
10116                .chunks(2)
10117                .map(|pair| {
10118                    let mut tx = Transaction::new();
10119                    tx.inputs.push(spending(&pair[0]));
10120                    tx.inputs.push(spending(&pair[1]));
10121                    tx.outputs.push(TransactionOutput::new(0, op_true()));
10122                    beef.merge_raw_tx(tx.to_binary(), None);
10123                    tx.id()
10124                })
10125                .collect();
10126        }
10127        (beef.to_binary(), root, layer.remove(0))
10128    }
10129
10130    #[test]
10131    fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
10132        for k in [12u32, 13] {
10133            let (bytes, root, subject) = proven_fan_in(k);
10134            let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
10135            let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
10136            println!(
10137                "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
10138                took.as_secs_f64()
10139            );
10140            assert_eq!(verdict, Ok(()), "{what}");
10141        }
10142    }
10143}