Skip to main content

bsv_overlay_engine/
engine.rs

1//! Overlay Services Engine — the core orchestrator.
2//!
3//! Receives transactions (submit), answers queries (lookup), manages advertisements,
4//! and coordinates GASP sync. All storage, topic management, and lookup service
5//! operations go through trait interfaces.
6//!
7//! Ported from `~/bsv/overlay-services/src/Engine.ts` (1,337 lines).
8
9use crate::beef_limits;
10
11use bsv_rs::transaction::{Beef, Transaction};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use tracing::{error, info, warn};
15
16use crate::advertiser::{Advertiser, AdvertiserError};
17use crate::broadcaster::{ArcBroadcaster, Broadcaster};
18use crate::lookup_service::{LookupService, LookupServiceError};
19use crate::storage::{Storage, StorageError};
20use crate::topic_manager::{TopicManager, TopicManagerError};
21use crate::types::*;
22
23/// Controls how much spend history to include in lookup responses.
24///
25/// Maps to the TS `historySelector` parameter which can be a number
26/// (depth limit) or an async function (per-output decider).
27#[derive(Debug, Clone)]
28pub enum HistorySelector {
29    /// Include up to N levels of ancestor spend history.
30    Depth(u32),
31}
32
33/// Configuration for constructing the Engine.
34pub struct EngineConfig {
35    /// URL where this engine is hosted. Required for advertisement sync.
36    pub hosting_url: Option<String>,
37    /// Known SHIP tracker URLs for bootstrapping.
38    pub ship_trackers: Vec<String>,
39    /// Known SLAP tracker URLs for bootstrapping.
40    pub slap_trackers: Vec<String>,
41    /// Configuration for GASP topic synchronization.
42    pub sync_configuration: SyncConfiguration,
43    /// Whether to suppress default SHIP/SLAP sync advertisements.
44    pub suppress_default_sync_advertisements: bool,
45}
46
47impl Default for EngineConfig {
48    fn default() -> Self {
49        Self {
50            hosting_url: None,
51            ship_trackers: Vec::new(),
52            slap_trackers: Vec::new(),
53            sync_configuration: HashMap::new(),
54            suppress_default_sync_advertisements: true,
55        }
56    }
57}
58
59/// The Overlay Services Engine.
60///
61/// Orchestrates topic managers, lookup services, storage, and advertisements.
62/// Does NOT include an HTTP server — that's the job of overlay-cloudflare.
63pub struct Engine {
64    managers: HashMap<String, Box<dyn TopicManager>>,
65    lookup_services: HashMap<String, Box<dyn LookupService>>,
66    storage: Box<dyn Storage>,
67    advertiser: Option<Box<dyn Advertiser>>,
68    broadcaster: Option<Box<dyn Broadcaster>>,
69    arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
70    chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
71    gasp_remote_factory: Option<Box<dyn crate::gasp::GASPRemoteFactory>>,
72    ship_lookup_client: Option<Box<dyn crate::ship_lookup::ShipLookupClient>>,
73    ship_bootstrap_peers: HashMap<String, Vec<String>>,
74    /// OPT-IN / OFF BY DEFAULT chain-backed ancestor fetcher for GASP ingest.
75    /// When `None` (default / production), GASP ingest is byte-identical to
76    /// today. A platform crate may set `Some` (one-time-migration only) to let
77    /// ingest self-heal missing ancestry by fetching it from chain.
78    ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
79    gasp_ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
80    /// bsv-low#302: per-peer GASP sync wall-clock budget —
81    /// `(sleep factory, budget ms)`. `None` (default) = unbounded per-peer
82    /// sync, byte-identical to the pre-#302 behavior. When set, each peer's
83    /// `GASPSync::sync` is raced against `sleep(budget_ms)`; a peer that
84    /// exceeds the budget is DROPPED (loud log, failure recorded, cursor NOT
85    /// advanced) and the loop continues with the next peer.
86    peer_sync_budget: Option<(SleepFactory, u64)>,
87    /// The per-GRAPH GASP budget (bsv-low #555): the sleep, the calls, the
88    /// ms. `None`: no graph is deferred.
89    graph_budget: Option<(SleepFactory, u32, u64)>,
90    /// The per-graph budget's bytes and nodes limbs (bsv-low #586,
91    /// [`Engine::set_graph_budget_limbs`]): in force once a graph budget is
92    /// set.
93    graph_budget_limbs: (u64, u32),
94    /// The bound of one transaction's finalize submit (bsv-low #559), see
95    /// [`Engine::set_finalize_submit_budget`].
96    finalize_submit_budget: Option<(SleepFactory, u64)>,
97    /// Open around each GASP finalize submit, so no deadline that races the
98    /// sync drops a transaction between its writes (bsv-low #552, the lens
99    /// fold's HIGH-1). See [`Engine::finalize_submit_gate`].
100    finalize_gate: crate::gasp::SubmitGate,
101    /// (txid, topic) pairs whose Phase-3 writes FAULTED in this engine and
102    /// have not landed since (bsv-low #559): a successor that finds no coin
103    /// while its predecessor is in here is not recorded as applied. One
104    /// invocation's memory; across invocations the same question is put to
105    /// the store ([`Engine::unlanded_predecessor`]).
106    not_landed: std::cell::RefCell<HashSet<(String, String)>>,
107    /// Asked before the door lands a carried predecessor (bsv-low #575, the
108    /// E1D delta-2 fold, L2). See [`Engine::set_landing_guard`].
109    landing_guard: Option<LandingGuard>,
110    /// Reference-parity spend verification on submit (2026-09-08). `true`
111    /// (DEFAULT): every submit outside `HistoricalTxNoSpv` runs the
112    /// reference's `Transaction.verify` walk: merkle paths against the chain
113    /// tracker AND every input's unlocking script EXECUTED, recursively over
114    /// the unproven ancestry in the BEEF; with no chain tracker the walk is
115    /// the ts-sdk's `'scripts only'` (roots accepted unchecked, scripts still
116    /// run). `false` is an ESCAPE HATCH, not a mode: it restores the
117    /// pre-2026-09-08 structural check (`Beef::verify_valid` + every root
118    /// against the tracker; NOTHING without a tracker), which admitted an
119    /// invalid spend on structure alone. See
120    /// [`Engine::set_script_verification`].
121    verify_scripts: bool,
122    /// The two limbs of the submit's own script walk (bsv-low #592): the
123    /// door's [`DoorBudget`], [`DoorBudget::DEFAULT`] unless set. See
124    /// [`Engine::set_walk_budget`].
125    walk_budget: DoorBudget,
126    config: EngineConfig,
127}
128
129/// Boxed platform sleep future (bsv-low#302). Not `Send` — the engine runs
130/// single-threaded on wasm (Workers) and under `#[tokio::test]` locally.
131pub type SleepFuture = std::pin::Pin<Box<dyn std::future::Future<Output = ()>>>;
132
133/// Platform sleep factory (bsv-low#302): milliseconds → a future that
134/// resolves after that delay. The Cloudflare crate passes its `sleep_ms`;
135/// tests pass `ready(())` (instant deadline) or `pending()` (no deadline)
136/// to drive the race deterministically.
137pub type SleepFactory = std::rc::Rc<dyn Fn(u64) -> SleepFuture>;
138
139/// The answer of a [`LandingGuard`]: `Ok` lets the body land, `Err` says why
140/// it may not (the topic waits).
141pub type LandingGuardFuture =
142    std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
143
144/// The caller's admission predicate over a carried predecessor the door is
145/// about to land: `(predecessor txid, topic)`. See
146/// [`Engine::set_landing_guard`].
147pub type LandingGuard = std::rc::Rc<dyn Fn(&str, &str) -> LandingGuardFuture>;
148
149/// The bound of ONE GASP finalize submit's storage calls and hooks (bsv-low
150/// #559, the lens fold of 2026-10-07, F2). The submit itself is never
151/// dropped: it is the write section no deadline drops, and c9921ee, which
152/// raced the whole submit against its budget inside the section, dropped it
153/// between two writes all the same. Each storage call, lookup hook and topic
154/// manager call inside it is raced against the section's ONE deadline
155/// instead, and a call that has not answered when the deadline falls due is
156/// dropped ALONE and reported as that call's fault: the submit runs on to
157/// its end as for any storage fault. Once the deadline is due no further
158/// call is started (each answers "no answer" at once), so a transaction
159/// costs at most its budget, and once more for the undo
160/// ([`CallBound::renew`]).
161struct CallBound {
162    sleep: SleepFactory,
163    budget_ms: u64,
164    deadline: std::cell::RefCell<SleepFuture>,
165    due: std::cell::Cell<bool>,
166}
167
168impl CallBound {
169    fn new(sleep: &SleepFactory, budget_ms: u64) -> Self {
170        Self {
171            deadline: std::cell::RefCell::new(sleep(budget_ms)),
172            sleep: sleep.clone(),
173            budget_ms,
174            due: std::cell::Cell::new(false),
175        }
176    }
177
178    /// A fresh allowance of the same length, for the undo of a faulted
179    /// submit: the calls that put the store back as it was found must not be
180    /// refused because the deadline the fault answered to has passed.
181    fn renew(&self) {
182        *self.deadline.borrow_mut() = (self.sleep)(self.budget_ms);
183        self.due.set(false);
184    }
185
186    /// Whether a call under this bound went unanswered (or was not started
187    /// because one had) since the last [`CallBound::renew`].
188    fn is_due(&self) -> bool {
189        self.due.get()
190    }
191
192    /// `call`, or `None` when the deadline fell due first.
193    async fn call<T>(&self, call: impl std::future::Future<Output = T>) -> Option<T> {
194        if self.due.get() {
195            return None;
196        }
197        let mut call = std::pin::pin!(call);
198        std::future::poll_fn(|cx| {
199            if let std::task::Poll::Ready(v) = call.as_mut().poll(cx) {
200                return std::task::Poll::Ready(Some(v));
201            }
202            if self.deadline.borrow_mut().as_mut().poll(cx).is_ready() {
203                self.due.set(true);
204                return std::task::Poll::Ready(None);
205            }
206            std::task::Poll::Pending
207        })
208        .await
209    }
210}
211
212/// `call` under `bound` (or unbounded with none); `Err` is "no answer".
213async fn bounded<T>(
214    bound: Option<&CallBound>,
215    call: impl std::future::Future<Output = T>,
216) -> Result<T, String> {
217    match bound {
218        Some(bound) => bound
219            .call(call)
220            .await
221            .ok_or_else(|| format!("no answer within {} ms", bound.budget_ms)),
222        None => Ok(call.await),
223    }
224}
225
226/// A storage call under `bound`: no answer in time is that call's fault.
227async fn stored<T>(
228    bound: Option<&CallBound>,
229    call: impl std::future::Future<Output = Result<T, StorageError>>,
230) -> Result<T, StorageError> {
231    match bounded(bound, call).await {
232        Ok(answer) => answer,
233        Err(no_answer) => Err(StorageError::Database(no_answer)),
234    }
235}
236
237/// A lookup hook under `bound`, its error as the report carries it.
238async fn hooked(
239    bound: Option<&CallBound>,
240    call: impl std::future::Future<Output = Result<(), LookupServiceError>>,
241) -> Result<(), String> {
242    bounded(bound, call).await?.map_err(|e| e.to_string())
243}
244
245/// The most store reads [`Engine::unlanded_predecessor`] makes for one
246/// topic of one submit (bsv-low #559, F3): the question is asked of every
247/// transaction that admits nothing and found no coin, so it is kept cheap.
248/// Counted are the reads spent on a body the BEEF does not prove and the
249/// store does not hold as landed (the second delta fold of 2026-10-07, M2);
250/// the 17th of those is never made: out of reads is "not now" (the delta
251/// fold of 2026-10-07, M2). An unproven body still needs room under it for
252/// the reads that find it landed (one for an applied row, two for a held
253/// output): each is tested before it is made.
254const PREDECESSOR_READS: usize = 16;
255
256/// The most store reads [`Engine::unlanded_predecessor`] makes for one
257/// SUBMIT (the third delta fold of 2026-10-07, M1): every read of the
258/// question, counted against [`PREDECESSOR_READS`] or not (those of proven
259/// and landed bodies included), in every topic of the submit together. The
260/// 257th is never made: past the allowance the answer is "not now", never
261/// "landed". Without it the question read as far as the BEEF reached (one
262/// read per 41 bytes: 66,305 reads measured from one 2.7 MB BEEF), on a
263/// public route. Per submit and not per topic because what it protects, the
264/// invocation's D1 allowance, is per invocation; 256 is the door's own unit
265/// ([`DoorBudget`]'s inputs per transaction), no derived figure.
266const PREDECESSOR_READS_PER_SUBMIT: usize = 256;
267
268/// Why a successor must wait ([`Engine::unlanded_predecessor`]), and the
269/// unlanded predecessor whose body the BEEF carries, when the answer names
270/// one: the door lands it first ([`Engine::land_carried`]).
271/// A submit's STEAK and report, and the carried predecessor that blocks each
272/// topic of a carried predecessor's own submit ([`Engine::submit_counted`]).
273type Counted = (Steak, MutationReport, HashMap<String, String>);
274
275/// What the landings of carried predecessors take from their successor's
276/// submit (the E592 lens fold, M1): its breach policy, and whether its own
277/// walk RAN (only then are the bodies it reached trusted).
278#[derive(Debug, Clone, Copy)]
279struct Landing {
280    policy: WalkBreachPolicy,
281    subject_walk_ran: bool,
282}
283
284struct NotNow {
285    why: String,
286    carried: Option<String>,
287}
288
289/// Summary of an [`Engine::complete_missing_proofs`] pass.
290///
291/// All counts are over the single bounded page scanned this tick. A no-fetcher
292/// (production) build returns the all-zero default.
293#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
294pub struct ProofCompletionSummary {
295    /// Young walk-admitted rows selected without waiting for a callback.
296    pub early_gasp: usize,
297    /// Stored transactions inspected this tick (the bounded page size).
298    pub scanned: usize,
299    /// Of `scanned`, how many still lacked a merkle proof for their own tx.
300    pub proofless: usize,
301    /// Proofless txs whose BUMP was fetched + stitched into the stored BEEF.
302    pub completed: usize,
303    /// Proofless txs the fetcher could serve but that are not yet mined
304    /// (no BUMP yet) — retried on a later tick.
305    pub still_unconfirmed: usize,
306    /// Proofless txs the fetcher errored on (e.g. budget exhausted, 429) —
307    /// retried on a later tick.
308    pub fetch_failed: usize,
309    /// Proofless txs whose proof came back but failed to stitch into storage.
310    pub stitch_failed: usize,
311    /// Scanned rows that ALREADY carried a valid proof (stale `has_proof = 0`
312    /// flag) and were marked proven this tick so they drop out of the candidate
313    /// window — clearing the window-clog (#130).
314    pub already_proven: usize,
315}
316
317/// Outcome of one [`Engine::sync_advertisements`] run (bsv-low #320 defect
318/// 3a).
319///
320/// The engine used to swallow create/submit failures into `error!` and
321/// return `Ok(())`, so the admin route reported `success` while zero
322/// advertisements were admitted locally; a caller could not tell a
323/// converged no-op (`to_create == 0`) from a silent failure. Every field
324/// here is observational — the sync's behavior is unchanged, its outcome is
325/// no longer hidden.
326#[derive(Debug, Default, Clone, Serialize)]
327#[serde(rename_all = "camelCase")]
328pub struct SyncAdvertisementsReport {
329    /// Advertisements the diff found missing (attempted this run). A
330    /// converged node reports 0 here.
331    pub to_create: usize,
332    /// Stale advertisements the diff said to revoke.
333    pub to_revoke: usize,
334    /// `advertiser.create_advertisements` failure, verbatim.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub create_error: Option<String>,
337    /// Local `Engine::submit` failure for the created ads, verbatim — the
338    /// path that silently hid the self-admission failure live (our own
339    /// ls_ship/ls_slap never gained our ads, so every cycle re-created all
340    /// of them).
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub submit_error: Option<String>,
343    /// Outputs admitted per topic by the local submit (from the STEAK).
344    /// A successful submit that admitted NOTHING (topic-manager refusal)
345    /// shows up here as explicit zeros.
346    pub admitted: std::collections::BTreeMap<String, usize>,
347    /// `advertiser.find_all_advertisements` failure, verbatim (#320 M2).
348    /// When set, creation was REFUSED this run: a blind read must never
349    /// become "current ads = none" and re-create (re-pay) the entire set.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub lookup_error: Option<String>,
352    /// Stale ads the diff wanted revoked but the advertiser declined to
353    /// build a revocation for (the CF advertiser's documented v1 no-op —
354    /// #320 L2). Distinguishes "revoke happened" from "revoke skipped".
355    pub revoke_skipped: usize,
356    /// `advertiser.revoke_advertisements` failure, verbatim.
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub revoke_error: Option<String>,
359    /// Local `Engine::submit` failure for the revocation, verbatim.
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub revoke_submit_error: Option<String>,
362}
363
364impl SyncAdvertisementsReport {
365    /// True when nothing errored. NOTE: a submit that admitted zero outputs
366    /// is not an error at this layer — that refusal is [`Self::effective`]'s
367    /// job.
368    #[must_use]
369    pub fn ok(&self) -> bool {
370        self.create_error.is_none()
371            && self.submit_error.is_none()
372            && self.lookup_error.is_none()
373            && self.revoke_error.is_none()
374            && self.revoke_submit_error.is_none()
375    }
376
377    /// Total outputs admitted by the local submit across topics.
378    #[must_use]
379    pub fn admitted_total(&self) -> usize {
380        self.admitted.values().sum()
381    }
382
383    /// True when the run errored nowhere AND the local submit admitted
384    /// EVERY created ad (#320 M1 + delta D1): each advertisement is exactly
385    /// one output and change is never a SHIP/SLAP token, so full admission
386    /// means `admitted_total() >= to_create` (defensive `>=`). A weaker
387    /// `> 0` bar let a PARTIAL admit pass — the live set is 9 SHIP + 8 SLAP
388    /// in ONE tx with independent per-topic admission, so tm_ship admitting
389    /// its 9 while tm_slap refused all 8 read as success while the diff
390    /// re-created (re-paid for) the refused 8 every cycle. Subsumes the
391    /// converged no-op (`0 >= 0`).
392    #[must_use]
393    pub fn effective(&self) -> bool {
394        self.ok() && self.admitted_total() >= self.to_create
395    }
396}
397
398/// Internal result from Phase 1 + 2 validation.
399///
400/// Carries per-topic admittance decisions, previous coins, and the parsed
401/// transaction so that Phase 3 (mutations) can proceed without re-parsing.
402struct TopicValidation {
403    topic: String,
404    is_dupe: bool,
405    /// Input indices that spend previously-admitted outputs from this topic.
406    previous_coins: Vec<u32>,
407    /// The previous outputs found in storage (parallel to previous_coins).
408    previous_outputs: Vec<Output>,
409    admittance: AdmittanceInstructions,
410    failed: bool,
411    /// The FIRST storage read fault hit while scanning this topic's
412    /// previous outputs (S2, bsv-low 2026-08-29). A faulted read makes a
413    /// spend look like it consumes nothing — the settle's spend pointer is
414    /// then never written — so it is carried into the [`MutationReport`]
415    /// and blocks the `applied_transactions` record for this topic: a
416    /// replay re-reads instead of being deduplicated away. With the read
417    /// that faulted: `find_output` (a previous coin) or
418    /// `does_applied_transaction_exist` (the dedup read, the delta fold of
419    /// 2026-10-07, H3).
420    read_fault: Option<(&'static str, String)>,
421}
422
423/// One Phase-3 write — or the validation read it depends on — that FAILED
424/// during a submit (S2 queue-durable admission, bsv-low 2026-08-29).
425///
426/// Before this existed every such failure was `error!`-logged and swallowed
427/// while `submit` returned `Ok(steak)`: under a D1-overload storm the
428/// overlay ACKED admissions whose writes never landed (the 2026-08-26
429/// phantom class — `c4d2ed06…`/`62c2a9b3…` absent from the engine store,
430/// beta D1 queried directly). A fault is now a VALUE the caller can act on
431/// (re-queue for an idempotent replay) instead of a log line nobody reads.
432#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct MutationFault {
434    /// The topic whose mutation faulted.
435    pub topic: String,
436    /// The write (or read) that failed: `insert_output`,
437    /// `mark_utxo_as_spent`, `update_consumed_by`, `delete_utxo_deep`,
438    /// `insert_applied_transaction`, `find_output` (validation read),
439    /// `does_applied_transaction_exist` (the dedup read),
440    /// `lookup_service.output_spent`, `lookup_service.output_admitted_by_topic`,
441    /// `undo_insert_output` (the undo of a faulted submit's inserts),
442    /// `record_spent_coin_applied` (the applied row a spender writes for the
443    /// transaction of a coin it deletes), `predecessor_not_landed`.
444    pub site: &'static str,
445    /// The backend's own error text.
446    pub error: String,
447}
448
449/// What Phase 3 actually landed for a submit — the durability report.
450///
451/// `applied_topics` are the topics whose EVERY write (and lookup-service
452/// notification) succeeded and were therefore recorded in
453/// `applied_transactions`. A topic with any fault is deliberately NOT
454/// recorded: the dedup in Phase 1 would otherwise turn a replay into a
455/// no-op and make the loss permanent (the ordering was already
456/// write-then-record; the record is now conditional on the writes).
457#[derive(Debug, Default, Clone, PartialEq, Eq)]
458pub struct MutationReport {
459    pub faults: Vec<MutationFault>,
460    pub applied_topics: Vec<String>,
461    /// Topics this (txid, topic) pair had ALREADY been applied under — the
462    /// Phase-1 dedup skipped them, so their STEAK entry is an empty default
463    /// that means "nothing new", never "judged another tx" (loop-3 client
464    /// belt re-presents read the empty STEAK as index-pending).
465    pub deduped_topics: Vec<String>,
466    /// The predecessors the door LANDED first from this submit's BEEF, as
467    /// `(txid, topic)`, ancestors first ([`Engine::land_carried`]; the E1D
468    /// delta fold, L2). Each is an admission write of its own that no
469    /// caller asked for: a caller that guards its admission writes (the
470    /// worker's eviction ledger, bsv-low #513) guards these too.
471    pub landed_predecessors: Vec<(String, String)>,
472    /// The submit's own script walk could not run within the engine's
473    /// budget (bsv-low #592) and the submit went on as under
474    /// `HistoricalTxNoSpv` ([`WalkBreachPolicy::NetworkAccepted`] only; under
475    /// `NotNow` a breach is [`EngineError::WalkCouldNotRun`]). `None` when it
476    /// ran (or the mode does not walk). A caller counts it; it never makes a
477    /// report undurable.
478    pub walk_could_not_run: Option<WalkCouldNotRun>,
479    /// The walks of the carried predecessors this submit tried to land
480    /// ([`Engine::land_carried`]) that could not run within the budget (the
481    /// E592 lens fold, L3): under `NotNow` each one's landing did not happen
482    /// (the successor waits), under `NetworkAccepted` each went on. Counted
483    /// by the caller beside `walk_could_not_run`.
484    pub landed_walks_could_not_run: Vec<WalkCouldNotRun>,
485}
486
487/// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): what [`Engine::renotify_admitted`] did.
488#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
489pub struct RenotifyReport {
490    /// stored outputs of (txid, topic) the engine had ALREADY admitted
491    pub outputs: u32,
492    /// lookup-service notifications that returned Ok (outputs × services)
493    pub notified: u32,
494    /// notifications that failed — `<service>: <error>`; the others still ran
495    pub faults: Vec<String>,
496    /// the output indexes re-notified (the caller's spend discovery keys on them)
497    pub vouts: Vec<u32>,
498}
499
500impl MutationReport {
501    fn fault(&mut self, topic: &str, site: &'static str, error: String) {
502        self.faults.push(MutationFault {
503            topic: topic.to_string(),
504            site,
505            error,
506        });
507    }
508
509    /// True when every mutation this submit needed landed (nothing to
510    /// replay). A dupe-only submit is durable (there was nothing to write).
511    #[must_use]
512    pub fn is_durable(&self) -> bool {
513        self.faults.is_empty()
514    }
515
516    /// One log line naming every fault (`topic/site: error`), each error
517    /// clipped so a D1 stack dump cannot flood the log stream.
518    #[must_use]
519    pub fn summary(&self) -> String {
520        self.faults
521            .iter()
522            .map(|f| {
523                let err: String = f.error.chars().take(120).collect();
524                format!("{}/{}: {}", f.topic, f.site, err)
525            })
526            .collect::<Vec<_>>()
527            .join("; ")
528    }
529}
530
531/// The DOOR's static work bound (bsv-low W-A gate MED-1, 2026-09-09). Bitcoin
532/// script has no loops: every opcode runs at most once, so an input's work is
533/// bounded BEFORE execution by its opcode census times the interpreter's
534/// element limit (the stack memory limit bounds every element, and every
535/// CAT / NUM2BIN / SPLIT result with it) plus its signature checks times the
536/// transaction's own size or a floor, whichever is more (a BIP-143 preimage
537/// hashes the prevouts and the outputs; the EC verification costs the same
538/// whatever the transaction weighs). The estimate is computed from the bytes;
539/// nothing executes past the budget, and a budget breach is the DOOR's
540/// verdict (inconclusive: the network judges), never the interpreter's.
541///
542/// THE BODY IS NOT COUNTED (bsv-low #585, door 1). Until #585 the budget also
543/// counted the BODY: 64 unproven transactions, 256 inputs per transaction,
544/// 512 KB per transaction, and the parse's own size and counts. A valid BEEF
545/// is never left unjudged for its size or its counts, so those are gone: the
546/// walk reads the body through the streaming reader ([`crate::script_door`]).
547///
548/// TWO LIMBS, both a budget whose breach is "the network judges" and never a
549/// refusal: the WORK the interpreter will do, and the MEMORY the door will
550/// hold beside the caller's body. Each is estimated from the bytes before it
551/// is spent.
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553pub struct DoorBudget {
554    /// The interpreter's stack memory limit: bounds every element, and is the
555    /// unit of the work estimate (a hash-class opcode is charged one element).
556    /// Not a bound on the BEEF: a script that trips it is "the network
557    /// judges", as a breach of the work is.
558    pub memory_limit: usize,
559    /// The whole walk's estimated work, in bytes hashed or pushed.
560    pub max_work_bytes: u64,
561    /// The least a signature check is charged (the doors lens E585-D12-M1).
562    /// A check is charged `max(its transaction's bytes, this)`: the digest
563    /// hashes about the transaction, and the EC verification behind it costs
564    /// the same whatever the transaction weighs (about 95 us native). Charged
565    /// the transaction's bytes alone, a 106 byte `OP_CHECKSIG OP_NOT`
566    /// transaction bought a verification for 106 bytes of budget: 90,000 of
567    /// them walked at a fifth of the budget in 9.6 s. With the floor the
568    /// budget bounds the EC verifications of one walk at
569    /// `max_work_bytes / sig_check_floor`.
570    pub sig_check_floor: u64,
571    /// The whole walk's estimated memory beside the body, in bytes (the doors
572    /// lens E585-D12-L3): what the index, the stream's element in hand and
573    /// the walk's layout of a transaction hold, estimated from the frame's
574    /// lengths and counts by [`crate::stream_sizing::estimate`] BEFORE the
575    /// stream is opened; and, per input, what its two scripts hold once
576    /// parsed and run (the delta lens E585-D12-DELTA-M1: bsv-rs's chunk
577    /// records, their clones, a signature's subscript, the empty stack
578    /// entries the interpreter's memory limit does not count), charged from
579    /// the scripts' chunk count and bytes BEFORE either is parsed
580    /// (`script_door::script_parse_charge`, 512 bytes a chunk and 8 a byte).
581    ///
582    /// How the two compose: the frame's estimate is the most the walk holds
583    /// at any moment apart from the input being run (what is kept, and the
584    /// heaviest transaction in hand, counted once with its raw bytes and the
585    /// interpreter's raw copies of the unlocking script); the scripts' charge
586    /// is the EXPANSION of one input's scripts past those bytes, held while
587    /// that input runs and released after it. An input is run only while the
588    /// frame's estimate plus its own charge is within this limb.
589    pub max_memory_bytes: u64,
590}
591
592impl DoorBudget {
593    /// Sized for LOW's real shapes with a wide margin. What the static census
594    /// BOUNDS: hashing (hash-class ops × the element limit) and signature
595    /// checks (× the transaction's size or the floor, whichever is more; a
596    /// CHECKMULTISIG weighted by its static key count when the count is a
597    /// small-int push, else by the most keys the element limit admits).
598    ///
599    /// THE FLOOR, 64 KiB (half the hash charge), chosen against LOW's shapes
600    /// as the pin `door_low_shapes_sit_under_a_quarter_of_the_work_budget`
601    /// measures them (work, of the 64 MiB; memory, the frame's and the
602    /// largest input's scripts, of the 48 MiB limb; work before the floor in
603    /// brackets):
604    ///
605    /// | shape | inputs | work | memory |
606    /// |---|---|---|---|
607    /// | the real covenant settle (5 hash opcodes, 4 checks: a CHECKSIG and the stated `OP_3` CHECKMULTISIG) | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
608    /// | the real covenant refund | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
609    /// | LOW's JOIN: two seats (bsv-low `template_tx_sizes.rs:76`), the real pot lock, each coin 40 unproven P2PKH hops deep (the deepest the fleet's record names) | 82 | 16,132,638, 24.0 % | 67,026, 0.13 % |
610    /// | STRESS, not LOW's shape: nine seats, three hops each | 36 | 7,082,619, 10.6 % (4,740,838) | 36,420, 0.07 % |
611    /// | a coin 30 P2PKH hops deep | 30 | 5,902,188, 8.8 % (3,941,856) | 23,720, 0.05 % |
612    ///
613    /// A P2PKH input is charged 196,739 bytes (a hash opcode at
614    /// the element limit, a check at the floor, its 131 script bytes): 341 of
615    /// them are the budget, 85 a quarter of it. The bound on EC verifications
616    /// per walk is 64 MiB / 64 KiB = 1,024 (1,023 with their script bytes):
617    /// measured natively, 1,000 of them walk in 87 ms in the release profile
618    /// (1.4 to 1.8 s in a debug build), and the lens's 17,000 stop at the
619    /// 1,024th in 108 ms. wasm32 was not measured.
620    ///
621    /// THE MEMORY, 48 MiB: three eighths of a 128 MB isolate. Beside it stand,
622    /// for a 10 MB request (the figure the arithmetic is made at: since NL-6
623    /// the route caps no body, so a heavier one is the isolate's), the
624    /// request's body, the completed BEEF the route hands the door (a second
625    /// copy of up to as much) and the EF batch (the route's 2 MB bound, twice
626    /// while it is serialized): 24 MB, which leaves 56 MB for the module, the
627    /// runtime, the allocator's fragmentation and what a native estimate does
628    /// not see of wasm32. The limb bounds the walk (the door's, and since
629    /// bsv-low #592 the submit's own and the GASP anchor's under
630    /// [`Engine::set_walk_budget`]) and nothing else: the route's own
631    /// hydrated parses before it are bounded by the isolate only (the land
632    /// lens E585-LAND-M1: 1.43 GB natively for a 9.9 MB body of 900,000
633    /// minimal transactions, before the route's 429). What
634    /// it lets through, by the door's charges (`script_door::DOOR_CHARGES`,
635    /// each an upper bound: the pins measure 1.3 to 5.3 times the heap the
636    /// walk reaches): a BUMP of up to 72,944 leaves (690 bytes a level-0
637    /// leaf); a single transaction of up to about 12 MB (4 bytes a byte)
638    /// whose walked inputs' scripts are few chunks (a script is charged 520
639    /// bytes a one-byte opcode and 8 a pushed byte: an input whose two
640    /// scripts are about 96,000 opcodes, or a 1 MB `OP_NOP` lock, is past
641    /// the limb before it is parsed, the network judges; LOW's covenant leg,
642    /// 6,608 script bytes, is charged 1.3 MB); a 10 MB body of one-input,
643    /// one-output P2PKH transactions (191 bytes, charged 570: 30 MB). What it
644    /// stops: 111,848 transactions of no input and no output, whatever
645    /// follows them.
646    ///
647    /// THE OPEN RESIDUAL (the W-A gate's delta-verify, 2026-09-09, measured):
648    /// the interpreter itself meters nothing, so two classes stay charged
649    /// only by their script bytes — bignum arithmetic (`OP_MUL`/`OP_DIV`/
650    /// `OP_MOD` on operands up to the element limit: ~1.3 ms per 30 KB×30 KB
651    /// round, linear in rounds) and `OP_NUM2BIN`, which
652    /// allocates its size operand BEFORE the memory limit is consulted (up to
653    /// bsv-rs's 1 GB element size: an isolate kill, not a refusal). None of
654    /// them can refuse a spend (a breach and a trip are the door's bound and
655    /// the request proceeds); all of them cost the beta overlay CPU until
656    /// bsv-rs meters work dynamically (a `work_limit` charged per op with the
657    /// real operand sizes, and a pre-allocation check in NUM2BIN) — OWED
658    /// before any PROD flip of `SCRIPT_VERIFY_NETWORK_GATED`. Since #585 the
659    /// count of such rounds is bounded by the body alone (the script bytes
660    /// are charged, the transactions and inputs are not counted). A third,
661    /// as before #585 (the doors lens N7): an opcode that copies an element
662    /// (`OP_DUP`, `OP_CAT`, `OP_PICK`) is charged its one script byte and can
663    /// copy up to the element limit. The class the floor CLOSED: a
664    /// CHECKMULTISIG whose key count is a computed value was charged the most
665    /// keys the element limit admits (3,971) at its transaction's bytes and
666    /// run (~80 µs per key tried); at the floor those are 260 MB, past the
667    /// budget, so such an input is never run at the door (the network
668    /// judges). LOW's covenant states its count (`OP_3`).
669    pub const DEFAULT: DoorBudget = DoorBudget {
670        memory_limit: 128 * 1024,
671        max_work_bytes: 64 * 1024 * 1024,
672        sig_check_floor: 64 * 1024,
673        max_memory_bytes: 48 * 1024 * 1024,
674    };
675}
676
677/// Which limb of the [`DoorBudget`] a walk went past.
678#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679pub enum DoorLimb {
680    /// The static work estimate, or the interpreter's own memory limit
681    /// tripping inside one input.
682    Work,
683    /// The estimate of what the door holds beside the body.
684    Memory,
685}
686
687/// What [`Engine::run_validation`] answers: the topics' validations, the
688/// preliminary STEAK, the subject and its txid, and whether the walk could
689/// not run within the budget (bsv-low #592).
690type Validated = (
691    Vec<TopicValidation>,
692    Steak,
693    Transaction,
694    String,
695    Option<WalkCouldNotRun>,
696);
697
698/// Which limb of the [`DoorBudget`] a submit's own walk went past (bsv-low
699/// #592): the [`DoorLimb`] of the door's error, with the interpreter's own
700/// memory limit told apart from the static work estimate.
701#[derive(Debug, Clone, Copy, PartialEq, Eq)]
702pub enum WalkLimb {
703    /// The static work estimate.
704    OverWork,
705    /// The estimate of what the walk holds beside the body.
706    OverMemory,
707    /// The interpreter's own memory limit (the budget's element limit)
708    /// tripping inside one input.
709    InterpreterMemory,
710}
711
712impl WalkLimb {
713    /// The door's word for the same limb ([`DoorLimb`]: the interpreter's
714    /// trip is `Work` there).
715    #[must_use]
716    pub fn door_limb(self) -> DoorLimb {
717        match self {
718            WalkLimb::OverWork | WalkLimb::InterpreterMemory => DoorLimb::Work,
719            WalkLimb::OverMemory => DoorLimb::Memory,
720        }
721    }
722
723    /// The limb's name in a log line and a counter's reason.
724    #[must_use]
725    pub fn as_str(self) -> &'static str {
726        match self {
727            WalkLimb::OverWork => "over_work",
728            WalkLimb::OverMemory => "over_memory",
729            WalkLimb::InterpreterMemory => "interpreter_memory",
730        }
731    }
732}
733
734/// What a submit does when its own script walk could not run within the
735/// engine's [`DoorBudget`] (bsv-low #592; the E592 lens fold, H1, the owner's
736/// ruling 3a read with the lens): a breach is NEVER an admission without the
737/// network's word on record.
738///
739/// The work limb is a static estimate from the bytes, and a LOCK can be made
740/// to pass it cheaply (a bare `OP_CHECKMULTISIG` in a fabricated parent's
741/// lock is charged 3,971 keys). So a breach says nothing about whether the
742/// spend is valid, and on a path where nothing else judges it an admission
743/// would admit a spend nobody checked: the lens's forged spend of a held coin.
744#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
745pub enum WalkBreachPolicy {
746    /// "Not now" ([`EngineError::WalkCouldNotRun`]): nothing judged, nothing
747    /// written, never a final refusal. Every door but one: the ungated
748    /// modes, `/admin/readmit`, the peer crawler, a library consumer's
749    /// submit; the GASP anchor HOLDS its UTXO the same way.
750    #[default]
751    NotNow,
752    /// The network's ACCEPT of these bytes is on record (the queue's replay
753    /// of a gated submission, whose producer broadcast them and marked the
754    /// message): the submit goes on exactly as under `HistoricalTxNoSpv`,
755    /// reported on [`MutationReport::walk_could_not_run`]. The roots the walk
756    /// reached are still checked, and a bad one is still the `SpvError` it is.
757    NetworkAccepted,
758}
759
760/// A submit whose own script walk COULD NOT RUN within the engine's
761/// [`DoorBudget`] (bsv-low #592, the owner's ruling 3a of 2026-10-10). Shaped
762/// like [`EngineError::ScriptWalkOverBudget`]. Under
763/// [`WalkBreachPolicy::NotNow`] it is carried by
764/// [`EngineError::WalkCouldNotRun`] (not now); under
765/// [`WalkBreachPolicy::NetworkAccepted`] the submit went on as under
766/// `HistoricalTxNoSpv` and this says so on its [`MutationReport`]. The
767/// reference (`Engine.submit`, `tx.verify`) has no budget; this is the
768/// platform's addition, on the door's lineage (D8).
769#[derive(Debug, Clone, PartialEq, Eq)]
770pub struct WalkCouldNotRun {
771    /// The submitted transaction.
772    pub subject_txid: String,
773    /// The transaction the walk stopped at.
774    pub at_txid: String,
775    /// Whether the subject's own inputs had all executed before the stop.
776    pub subject_judged: bool,
777    /// The limb it went past.
778    pub limb: WalkLimb,
779    /// The estimate against the limb, in the door's words.
780    pub what: String,
781}
782
783/// What the walk did (the door's cost instrument, since `Date.now()` is
784/// frozen during synchronous work on Workers and cannot time it).
785#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
786pub struct WalkStats {
787    /// Unproven transactions whose inputs were executed.
788    pub unproven_txs: usize,
789    /// Inputs executed (scripts run).
790    pub inputs_executed: usize,
791    /// Unlocking + locking script bytes executed.
792    pub script_bytes: usize,
793    /// Hash-class opcodes seen (SHA1/SHA256/HASH160/HASH256/RIPEMD160).
794    pub hash_ops: usize,
795    /// Signature checks seen (CHECKSIG counts 1, CHECKMULTISIG counts its
796    /// consensus maximum of 20).
797    pub sig_ops: usize,
798    /// The static work estimate charged against the budget.
799    pub work_bytes: u64,
800    /// The memory estimate charged against the budget: the frame's, made
801    /// before the stream was opened, plus the largest charge of one walked
802    /// input's scripts, made before they were parsed.
803    pub memory_bytes: u64,
804    /// Whether the SUBJECT's own inputs were all executed (an ancestor may
805    /// still have faulted afterwards).
806    pub subject_judged: bool,
807}
808
809/// Static opcode census of one input's scripts: (bytes, hash ops, sig ops).
810///
811/// A CHECKMULTISIG is weighted by its KEY COUNT: the small-int push that
812/// precedes it when the script states the count (the Poc5 covenant's
813/// `OP_3`), else the most 33-byte keys the element limit admits (the count is
814/// a computed value the census cannot see; bsv-rs allows up to `i32::MAX`).
815pub(crate) fn script_census(
816    unlocking: &[u8],
817    locking: &[u8],
818    memory_limit: usize,
819) -> Result<(usize, usize, usize), String> {
820    use bsv_rs::script::op::*;
821    let most_keys = (memory_limit / 33).max(1);
822    let mut hash_ops = 0usize;
823    let mut sig_ops = 0usize;
824    for (label, bytes) in [("unlocking", unlocking), ("locking", locking)] {
825        let script = bsv_rs::script::Script::from_binary(bytes)
826            .map_err(|e| format!("{label} script does not parse: {e}"))?;
827        let chunks = script.chunks();
828        for (i, chunk) in chunks.iter().enumerate() {
829            match chunk.op {
830                OP_RIPEMD160 | OP_SHA1 | OP_SHA256 | OP_HASH160 | OP_HASH256 => hash_ops += 1,
831                OP_CHECKSIG | OP_CHECKSIGVERIFY => sig_ops += 1,
832                OP_CHECKMULTISIG | OP_CHECKMULTISIGVERIFY => {
833                    let stated = i
834                        .checked_sub(1)
835                        .map(|j| chunks[j].op)
836                        .and_then(|op| match op {
837                            OP_1..=OP_16 => Some((op - OP_1 + 1) as usize),
838                            _ => None,
839                        });
840                    sig_ops += stated.unwrap_or(most_keys);
841                }
842                _ => {}
843            }
844        }
845    }
846    Ok((unlocking.len() + locking.len(), hash_ops, sig_ops))
847}
848
849impl Engine {
850    /// Page size used by `/requestSyncResponse` when the (public) caller
851    /// omits `limit`. A bounded page is not lossy for a conforming
852    /// initiator: ours ([`crate::gasp::GASPSync`]) keeps requesting pages
853    /// for as long as the `since` score cursor advances (gate finding M1 —
854    /// it must NOT require a "full" page to continue, because this clamp
855    /// makes a full-by-the-initiator's-limit page impossible).
856    pub const SYNC_RESPONSE_DEFAULT_LIMIT: u64 = 500;
857    /// Hard cap on a `/requestSyncResponse` page. Before bsv-low #291 an
858    /// absent/huge caller-supplied limit meant an unbounded scan of the
859    /// outputs table on a public route.
860    pub const SYNC_RESPONSE_MAX_LIMIT: u64 = 1000;
861
862    /// Clamp the public `/requestSyncResponse` page size: absent → the
863    /// default page; anything larger than the cap → the cap. The result is
864    /// ALWAYS `Some`-worthy — a sync response is never unbounded (bsv-low
865    /// #291). Completeness relies on the INITIATOR paging while its `since`
866    /// cursor advances (see `SYNC_RESPONSE_DEFAULT_LIMIT`'s doc) — the
867    /// responder alone cannot page for it.
868    pub fn clamp_sync_limit(requested: Option<u64>) -> u64 {
869        requested
870            .unwrap_or(Self::SYNC_RESPONSE_DEFAULT_LIMIT)
871            .min(Self::SYNC_RESPONSE_MAX_LIMIT)
872    }
873
874    /// Create a new Overlay Services Engine.
875    pub fn new(
876        managers: HashMap<String, Box<dyn TopicManager>>,
877        lookup_services: HashMap<String, Box<dyn LookupService>>,
878        storage: Box<dyn Storage>,
879        advertiser: Option<Box<dyn Advertiser>>,
880        config: EngineConfig,
881    ) -> Self {
882        Self::with_chain_tracker(
883            managers,
884            lookup_services,
885            storage,
886            advertiser,
887            None,
888            None,
889            config,
890        )
891    }
892
893    /// Create a new Engine with an optional ChainTracker for SPV verification.
894    pub fn with_chain_tracker(
895        managers: HashMap<String, Box<dyn TopicManager>>,
896        lookup_services: HashMap<String, Box<dyn LookupService>>,
897        storage: Box<dyn Storage>,
898        advertiser: Option<Box<dyn Advertiser>>,
899        broadcaster: Option<Box<dyn Broadcaster>>,
900        chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
901        config: EngineConfig,
902    ) -> Self {
903        Self::with_all(
904            managers,
905            lookup_services,
906            storage,
907            advertiser,
908            broadcaster,
909            None,
910            chain_tracker,
911            config,
912        )
913    }
914
915    /// Create a new Engine with all optional components.
916    #[allow(clippy::too_many_arguments)]
917    pub fn with_all(
918        managers: HashMap<String, Box<dyn TopicManager>>,
919        lookup_services: HashMap<String, Box<dyn LookupService>>,
920        storage: Box<dyn Storage>,
921        advertiser: Option<Box<dyn Advertiser>>,
922        broadcaster: Option<Box<dyn Broadcaster>>,
923        arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
924        chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
925        mut config: EngineConfig,
926    ) -> Self {
927        // Build default sync configuration: SHIP for all topics except tm_ship/tm_slap
928        // which get their trackers merged with provided shipTrackers/slapTrackers.
929        for manager_name in managers.keys() {
930            if manager_name == "tm_ship" {
931                if matches!(
932                    config.sync_configuration.get(manager_name),
933                    Some(SyncTarget::Disabled)
934                ) {
935                    continue;
936                }
937                let mut combined: HashSet<String> = HashSet::new();
938                if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
939                {
940                    combined.extend(peers.iter().cloned());
941                }
942                combined.extend(config.ship_trackers.iter().cloned());
943                if !combined.is_empty() {
944                    config.sync_configuration.insert(
945                        manager_name.clone(),
946                        SyncTarget::Peers(combined.into_iter().collect()),
947                    );
948                }
949            } else if manager_name == "tm_slap" {
950                if matches!(
951                    config.sync_configuration.get(manager_name),
952                    Some(SyncTarget::Disabled)
953                ) {
954                    continue;
955                }
956                let mut combined: HashSet<String> = HashSet::new();
957                if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
958                {
959                    combined.extend(peers.iter().cloned());
960                }
961                combined.extend(config.slap_trackers.iter().cloned());
962                if !combined.is_empty() {
963                    config.sync_configuration.insert(
964                        manager_name.clone(),
965                        SyncTarget::Peers(combined.into_iter().collect()),
966                    );
967                }
968            } else if !config.sync_configuration.contains_key(manager_name) {
969                config
970                    .sync_configuration
971                    .insert(manager_name.clone(), SyncTarget::Ship);
972            }
973        }
974
975        Self {
976            managers,
977            lookup_services,
978            storage: Box::new(crate::load_storage::MeteredStorage(storage)),
979            advertiser,
980            broadcaster,
981            arc_broadcaster,
982            chain_tracker,
983            gasp_remote_factory: None,
984            ship_lookup_client: None,
985            ship_bootstrap_peers: HashMap::new(),
986            ancestor_fetcher: None,
987            gasp_ancestor_fetcher: None,
988            peer_sync_budget: None,
989            graph_budget: None,
990            graph_budget_limbs: (
991                crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES,
992                crate::gasp::DEFAULT_GRAPH_BUDGET_NODES,
993            ),
994            finalize_submit_budget: None,
995            finalize_gate: crate::gasp::SubmitGate::default(),
996            not_landed: std::cell::RefCell::new(HashSet::new()),
997            landing_guard: None,
998            verify_scripts: true,
999            walk_budget: DoorBudget::DEFAULT,
1000            config,
1001        }
1002    }
1003
1004    /// Set the ARC broadcaster for network broadcast to miners.
1005    ///
1006    /// When set, the Engine broadcasts transactions to ARC during Phase 2
1007    /// of `submit()` for `CurrentTx` submissions.
1008    pub fn set_arc_broadcaster(&mut self, arc: Box<dyn ArcBroadcaster>) {
1009        self.arc_broadcaster = Some(arc);
1010    }
1011
1012    /// Set the GASP remote factory for peer communication during sync.
1013    ///
1014    /// Platform-specific crates (like overlay-cloudflare) provide an implementation
1015    /// that creates HTTP-based remotes using their native fetch API.
1016    pub fn set_gasp_remote_factory(&mut self, factory: Box<dyn crate::gasp::GASPRemoteFactory>) {
1017        self.gasp_remote_factory = Some(factory);
1018    }
1019
1020    /// Set the platform lookup transport for network SHIP discovery.
1021    /// Trackers come from [`EngineConfig::slap_trackers`].
1022    pub fn set_ship_lookup_client(
1023        &mut self,
1024        client: Box<dyn crate::ship_lookup::ShipLookupClient>,
1025    ) {
1026        self.ship_lookup_client = Some(client);
1027    }
1028
1029    /// Keep these peers in a topic's SHIP union even when trackers fail.
1030    /// This leaves the existing [`SyncTarget`] serialization unchanged.
1031    /// Such topics run ahead of tracker imports so discovery and sync can
1032    /// proceed without spending the tick on importing discovery records.
1033    pub fn set_ship_bootstrap_peers(&mut self, topic: impl Into<String>, peers: Vec<String>) {
1034        self.ship_bootstrap_peers.insert(topic.into(), peers);
1035    }
1036
1037    /// Set the OPT-IN chain-backed ancestor fetcher for GASP ingest.
1038    ///
1039    /// **OFF BY DEFAULT.** When set, a peer's failure to serve a needed
1040    /// ancestor during GASP ingest falls back to fetching that ancestor's raw
1041    /// tx from chain (via the supplied fetcher), self-healing the graph and
1042    /// also enabling strict-BEEF finalize so missing ancestry fails loud.
1043    ///
1044    /// This is a deliberate one-time-migration escape hatch. Production must
1045    /// NOT call this — when unset, GASP ingest behavior is unchanged.
1046    pub fn set_ancestor_fetcher(&mut self, fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>) {
1047        self.ancestor_fetcher = Some(fetcher);
1048    }
1049
1050    /// Supply a courier reserved for sync. Completion retains its own budget.
1051    pub fn set_gasp_ancestor_fetcher(
1052        &mut self,
1053        fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>,
1054    ) {
1055        self.gasp_ancestor_fetcher = Some(fetcher);
1056    }
1057
1058    /// Set the admission predicate asked BEFORE the door lands a carried
1059    /// predecessor (bsv-low #575, the E1D delta-2 fold, L2;
1060    /// [`Engine::land_carried`]). It is asked once per body the landing
1061    /// would submit, before that body's submit (a dry one included) and
1062    /// before its reads are charged; an `Err` (the caller's refusal, or its
1063    /// own read fault) ends the landing with nothing of that body written
1064    /// and the successor's topic answers "not now", as on `683dffd`. The
1065    /// worker installs its eviction ledger here: an OPEN eviction of the
1066    /// body refuses it. Its read is not one of the submit's 256 (it is the
1067    /// caller's store, at most one per landed body). Unset (default): every
1068    /// carried predecessor may land. What the predicate cannot see (a row
1069    /// opened while the landing writes) stays the caller's to guard after
1070    /// the write ([`MutationReport::landed_predecessors`]).
1071    pub fn set_landing_guard(&mut self, guard: LandingGuard) {
1072        self.landing_guard = Some(guard);
1073    }
1074
1075    /// Set the per-peer GASP sync budget (bsv-low#302).
1076    ///
1077    /// `sleep` is the platform sleep factory (ms → future); `budget_ms` is
1078    /// the wall-clock slice ONE peer's sync may consume. A peer exceeding it
1079    /// is dropped (loudly logged, listed in the topic's `errors`) and the
1080    /// loop moves on. Unset (default) = unbounded, the pre-#302 behavior.
1081    ///
1082    /// **Progress survives the deadline (bsv-low #552).** With a budget set,
1083    /// every graph is submitted AS IT FINALIZES, inside the raced future (the
1084    /// reference submits inside `finalizeGraph` too), so what was finalized
1085    /// before the deadline stays admitted. At the deadline the cursor is
1086    /// advanced to `GASPSync::completed_cursor`: past the UTXOs whose graphs
1087    /// were completed, never past the one in flight. That graph is lost
1088    /// whole (nothing of it was finalized) and the next tick walks it again,
1089    /// down to whatever is now in storage.
1090    ///
1091    /// **The deadline never lands inside a transaction's writes** (the lens
1092    /// fold's HIGH-1). It is cooperative around a finalize submit
1093    /// ([`Engine::finalize_submit_gate`]): a deadline that falls due while
1094    /// one transaction is being written waits for that transaction, and the
1095    /// sync is dropped at the boundary. What a dropped tick leaves of the
1096    /// graph being submitted is an ancestors-first prefix of WHOLE
1097    /// transactions, with the cursor below its UTXO. Everywhere else (a
1098    /// request to the peer, the walk, the anchor check) the deadline drops
1099    /// the sync at once, as a budget against a dead peer must. A dropped tick that finalized a
1100    /// graph or moved the cursor is recorded as a SUCCESSFUL attempt for the
1101    /// quarantine count: a peer serving a long bootstrap is not a dead peer.
1102    /// One with no progress is a failed attempt, as before.
1103    ///
1104    /// The budget bounds the work of a TICK. Without a per-graph budget
1105    /// nothing bounds a GRAPH (parity: the reference has no node cap): one
1106    /// graph whose own walk outlasts the budget is dropped on every tick
1107    /// (`deadline_dropped_graphs`) and never admitted. With one
1108    /// ([`Engine::set_graph_budget`], bsv-low #555) that walk is deferred
1109    /// with its progress kept, here too, and resumed next tick.
1110    pub fn set_peer_sync_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1111        self.peer_sync_budget = Some((sleep, budget_ms));
1112    }
1113
1114    /// Set the per-GRAPH GASP budget and turn DEFERRAL on (bsv-low #555).
1115    ///
1116    /// A graph whose walk makes `max_calls` requests (to the peer, or chain
1117    /// fetches) or runs `budget_ms` in one pass is DEFERRED: its partial walk
1118    /// (the nodes fetched with their proofs, the inputs still pending, the
1119    /// calls spent, its age in passes) is saved as ONE record of the storage
1120    /// ([`Storage::put_deferred_graph`], replaced on every deferral), its
1121    /// UTXO is held below the cursor as a failed one is (the gap guard) and
1122    /// not walked again in the sync (#554), and the sync goes on to the next
1123    /// UTXO and topic. The next sync that is served that UTXO RESUMES the
1124    /// walk from the record, asking only what is pending, under the same
1125    /// budget; a graph so converges over passes and is then completed as
1126    /// any graph is (#551's anchor check, the finalize submits, e1d's
1127    /// rules). A walk cut by the per-peer deadline is deferred the same way.
1128    ///
1129    /// Bounds: a record deferred [`crate::gasp::DEFERRED_GRAPH_MAX_PASSES`]
1130    /// times is dropped with its reason and the UTXO fails as before (the
1131    /// gap guard asks again, from the root); a record has no byte bound
1132    /// (bsv-low #585); at most
1133    /// [`crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC`] records per (peer,
1134    /// topic). Keep `budget_ms` below the per-peer budget so that a deep
1135    /// graph leaves the pass time for the UTXOs after it. Defaults offered:
1136    /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_CALLS`],
1137    /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_MS`].
1138    ///
1139    /// Every resume of one sync shares ONE such budget (the lens fold's M1),
1140    /// so the deferred UTXOs, served first, leave the pass the rest; a walk
1141    /// that fetched nothing keeps no record and is a failed attempt for the
1142    /// quarantine (H1); a walk that cannot be kept, or whose save faulted,
1143    /// goes on under the per-peer budget alone (L4, the delta fold's D-L2);
1144    /// a FRESH walk's error keeps no record (D-M1). With a budget, a sync the
1145    /// peer served work that finalized no graph and moved no cursor is
1146    /// YIELDLESS, and past [`crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED`] in a
1147    /// row AND [`crate::gasp::PEER_YIELDLESS_SECS_ALLOWED`] since the streak
1148    /// began each is a failed attempt (D-M2, the delta-2 fold's D2-M1; the
1149    /// streak is kept by [`Storage::record_peer_sync_yield`], keyed on the
1150    /// peer's [`crate::gasp::peer_origin`] as its quarantine is). A FRESH
1151    /// walk that faults after half its calls or half its time keeps its
1152    /// record (D2-L1); a RESUMED one that faults having appended nothing
1153    /// [`crate::gasp::DEFERRED_GRAPH_MAX_IDLE_FAULTS`] passes in a row is
1154    /// dropped (D2-M2). Each record says whether its peer is configured
1155    /// (`SyncTarget::Peers`), for a storage that reserves part of its
1156    /// ceiling for those (the worker reserves half).
1157    ///
1158    /// Unset (the default) nothing is deferred and the walk is the one
1159    /// before #555 (parity: the reference has no budget and no deferral).
1160    /// The storage must KEEP records (the four required deferred-graph
1161    /// methods of [`Storage`]): over one that refuses every save, every
1162    /// graph past the budget is walked from its root under the per-peer
1163    /// budget alone on every pass, as before #555, so do not set a budget
1164    /// there.
1165    ///
1166    /// The budget has two more limbs (bsv-low #586), in force with it at
1167    /// their defaults: the bytes one graph may be SERVED in one pass
1168    /// ([`crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES`], a budget per pass only:
1169    /// a record has no byte bound since bsv-low #585 door 4) and the nodes it
1170    /// may APPEND ([`crate::gasp::DEFAULT_GRAPH_BUDGET_NODES`], 64);
1171    /// [`Engine::set_graph_budget_limbs`] names others.
1172    pub fn set_graph_budget(&mut self, sleep: SleepFactory, max_calls: u32, budget_ms: u64) {
1173        self.graph_budget = Some((sleep, max_calls, budget_ms));
1174    }
1175
1176    /// The bytes and nodes limbs of the per-graph budget (bsv-low #586,
1177    /// zanaadu-v2 #377): a graph whose walk is SERVED `max_bytes_fetched`
1178    /// bytes in one pass (the hex of each node's raw transaction and proof,
1179    /// as the peer sends it and a record keeps it, every answer counted), or
1180    /// APPENDS `max_nodes` nodes, is DEFERRED exactly as at the call budget
1181    /// (reason `bytes` or `nodes`): its record saved, its UTXO held below
1182    /// the cursor, the walk resumed by the next pass from what is pending.
1183    /// Never a drop, a refusal or a discarded graph: these are budgets PER
1184    /// PASS, not limits on a graph or a BEEF. A pass always makes one
1185    /// request whatever a node's size; the resumes of one sync share one
1186    /// such budget, as they share its calls (M1); a walk that cannot be
1187    /// KEPT goes on under the per-peer budget alone (L4), as at the calls.
1188    /// They bound what one pass adds to the pending graph and to its record
1189    /// while the anchor check and the finalize hold it in memory. No effect
1190    /// until [`Engine::set_graph_budget`] turns deferral on; the reference
1191    /// has neither. This setter is UNCAPPED, as the worker's
1192    /// `GASP_GRAPH_BUDGET_BYTES` var is (bsv-low #585 door 4: the engine keeps
1193    /// no record cap for the limb to sit under). A consumer whose STORAGE caps
1194    /// a record must keep the bytes limb under that cap, or a pass whose
1195    /// record cannot be saved walks afresh (E586-L1).
1196    pub fn set_graph_budget_limbs(&mut self, max_bytes_fetched: u64, max_nodes: u32) {
1197        self.graph_budget_limbs = (max_bytes_fetched, max_nodes);
1198    }
1199
1200    /// Bound ONE transaction's GASP finalize submit (bsv-low #559, the delta
1201    /// lens's DELTA-3). That submit is the write section no deadline drops
1202    /// ([`Engine::finalize_submit_gate`]), so a storage call or lookup hook
1203    /// that never answers inside it held the per-peer budget and any outer
1204    /// race for as long as it hung. With this set EACH storage call, lookup
1205    /// hook and topic manager call of that submit is raced against one
1206    /// `sleep(budget_ms)` of the section (the lens fold of 2026-10-07, F2):
1207    /// a call still unanswered when it falls due is dropped alone and is
1208    /// that call's FAULT, and no call is started after it. The SUBMIT is
1209    /// never dropped: it runs to its end and leaves what a fault leaves
1210    /// ([`Engine::submit_with_report`]: nothing of the transaction, or all
1211    /// of it), the transaction did not land, the rest of its graph is not
1212    /// submitted, its UTXO fails and the cursor stays below it. The undo of
1213    /// a faulted submit has one more `sleep(budget_ms)` of its own, so a
1214    /// peer costs at most its budget plus twice this one. What the backend
1215    /// does with a statement whose caller stopped waiting is its own: a
1216    /// write that lands after its timeout is not seen by this submit.
1217    ///
1218    /// DEFAULT: none (a native engine has no clock). A caller that sets a
1219    /// per-peer budget should set this too.
1220    pub fn set_finalize_submit_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1221        self.finalize_submit_budget = Some((sleep, budget_ms));
1222    }
1223
1224    /// The gate around every GASP finalize submit (bsv-low #552, the lens
1225    /// fold's HIGH-1). `start_gasp_sync` races each peer against its budget
1226    /// with [`crate::gasp::race_or_deadline_guarded`] over this gate. A
1227    /// caller that races `start_gasp_sync` ITSELF against a deadline (the
1228    /// worker's scheduled step) must use the same function over the same
1229    /// gate: a plain `race_or_deadline` there can still drop a finalize
1230    /// submit between its writes.
1231    pub fn finalize_submit_gate(&self) -> &crate::gasp::SubmitGate {
1232        &self.finalize_gate
1233    }
1234
1235    /// Turn reference-parity script verification on submit on or off.
1236    /// **DEFAULT ON** (set in every constructor).
1237    ///
1238    /// On, every submit outside `HistoricalTxNoSpv` executes every unproven
1239    /// input's unlocking script against its source output (and checks every
1240    /// merkle path against the chain tracker), exactly as the reference's
1241    /// `Engine.submit` → `tx.verify(this.chainTracker)` does. Off is an
1242    /// ESCAPE HATCH for an operator who must admit a body the interpreter
1243    /// refuses (a bsv-rs interpreter defect, say) while it is fixed. It is
1244    /// NOT a mode: it restores the pre-2026-09-08 structural check, under
1245    /// which an invalid spend that no broadcaster had yet refused was
1246    /// admitted on BEEF structure alone. Prefer
1247    /// [`crate::builder::EngineBuilder::with_script_verification`].
1248    pub fn set_script_verification(&mut self, enabled: bool) {
1249        self.verify_scripts = enabled;
1250    }
1251
1252    /// Whether submits execute input scripts, see
1253    /// [`Engine::set_script_verification`]. `true` by default.
1254    pub fn script_verification(&self) -> bool {
1255        self.verify_scripts
1256    }
1257
1258    /// The budget of the submit's own script walk (bsv-low #592, the owner's
1259    /// ruling 3a of 2026-10-10). Every submit that walks (every mode but
1260    /// `HistoricalTxNoSpv`), and the GASP anchor check, read the BEEF through
1261    /// the door's stream walk ([`crate::script_door`]) under this budget's
1262    /// two limbs, the work and the memory, each estimated from the bytes
1263    /// before it is spent. A breach is "the walk could not run"
1264    /// ([`WalkCouldNotRun`], on [`MutationReport::walk_could_not_run`]),
1265    /// never a refusal: the submit goes on as under `HistoricalTxNoSpv`.
1266    /// A library consumer that sets nothing gets [`DoorBudget::DEFAULT`]
1267    /// (64 MiB of work, 48 MiB of memory). Prefer
1268    /// [`crate::builder::EngineBuilder::with_walk_budget`].
1269    pub fn set_walk_budget(&mut self, budget: DoorBudget) {
1270        self.walk_budget = budget;
1271    }
1272
1273    /// The budget of the submit's own script walk, see
1274    /// [`Engine::set_walk_budget`].
1275    pub fn walk_budget(&self) -> DoorBudget {
1276        self.walk_budget
1277    }
1278
1279    /// The reference's `tx.verify('scripts only')` over a BEEF: every unproven
1280    /// transaction from `subject_txid` down executes every input's unlocking
1281    /// script against its source output and obeys the value rule, while a
1282    /// transaction that carries a merkle path is TRUSTED AS-IS — no root is
1283    /// computed and no chain tracker is consulted (the ts-sdk's own shape when
1284    /// `chainTracker === 'scripts only'`: a proven tx is added to the verified
1285    /// set and the walk stops there).
1286    ///
1287    /// Built for an admission path whose bar is the NETWORK, not a proof
1288    /// (bsv-low's `broadcast-gated`, register row D1): the overlay broadcasts
1289    /// and admits on network evidence, so a lagging tracker must never read as
1290    /// a refused transaction — but a spend the interpreter refuses is one the
1291    /// network will refuse too, and executing it at the door saves the
1292    /// broadcast and names the fault. Independent of
1293    /// [`Engine::set_script_verification`] (that switch governs `submit`; this
1294    /// is an explicit ask) and of [`Engine::submit`]'s mode (`HistoricalTxNoSpv`
1295    /// still skips, as the reference does).
1296    ///
1297    /// Runs under the engine's budget ([`Engine::set_walk_budget`], the
1298    /// submit's own; [`DoorBudget::DEFAULT`] unless set: one budget per
1299    /// engine, the E592 lens fold's NOTE-2): the work is bounded statically
1300    /// before anything executes, and the interpreter's stack memory limit is
1301    /// the budget's element limit. The body is read through the streaming
1302    /// reader ([`crate::script_door`], bsv-low #585): no bound on its size,
1303    /// its transactions or a transaction's inputs or bytes; the budget's two
1304    /// limbs are the work and the memory beside the body.
1305    ///
1306    /// Errors: [`EngineError::ScriptVerificationFailed`] is the INTERPRETER's
1307    /// verdict and the only refusal; [`EngineError::ScriptWalkInconclusive`]
1308    /// is a structural fault (a transaction or source missing from the BEEF,
1309    /// a parse fault, the value rule) and [`EngineError::ScriptWalkOverBudget`]
1310    /// the door's own bound (the static budget or the memory limit) — both
1311    /// name whether the SUBJECT was judged before the fault, and a caller with
1312    /// a stronger bar behind it does not refuse on either.
1313    #[allow(
1314        clippy::unused_async,
1315        reason = "the door's callers await it on an engine; 'scripts only' asks no tracker"
1316    )]
1317    pub async fn verify_scripts_only(
1318        &self,
1319        beef_bytes: &[u8],
1320        subject_txid: &str,
1321    ) -> Result<WalkStats, EngineError> {
1322        crate::script_door::walk(beef_bytes, subject_txid, self.walk_budget)
1323    }
1324
1325    /// [`Engine::verify_scripts_only`] under a budget the caller names (the
1326    /// pins measure the door with a limb lifted; a caller with another
1327    /// platform's ceiling sets its own).
1328    #[allow(
1329        clippy::unused_async,
1330        clippy::unused_self,
1331        reason = "the twin of `verify_scripts_only`"
1332    )]
1333    pub async fn verify_scripts_only_under(
1334        &self,
1335        beef_bytes: &[u8],
1336        subject_txid: &str,
1337        budget: DoorBudget,
1338    ) -> Result<WalkStats, EngineError> {
1339        crate::script_door::walk(beef_bytes, subject_txid, budget)
1340    }
1341
1342    // ========================================================================
1343    // Submit — 3-phase pipeline
1344    // ========================================================================
1345
1346    /// Validate a tagged BEEF without applying mutations.
1347    ///
1348    /// Runs Phase 1 (topic validation) and Phase 2 (broadcast) but NOT Phase 3
1349    /// (storage mutations). Returns the Steak (admittance decisions) that
1350    /// `submit()` would return.
1351    ///
1352    /// Used by the onSteakReady pattern to return results to clients before
1353    /// mutations are applied via a queue consumer or `ctx.wait_until()`.
1354    ///
1355    /// The topic managers are called with `dry_run: true`
1356    /// (`TopicAdmittanceContext::DRY_RUN`): nothing is admitted on this call, so a
1357    /// manager that writes on admission leaves no trace of it.
1358    pub async fn submit_validate_only(
1359        &self,
1360        tagged_beef: &TaggedBEEF,
1361        mode: SubmitMode,
1362    ) -> Result<Steak, EngineError> {
1363        // A validate-only call admits nothing: a dry run by its own name. A
1364        // manager that writes on admission must not write here, or the real
1365        // submit that follows meets a head this call already advanced.
1366        let (_validations, steak, _tx, _txid, _could_not_run) = self
1367            .run_validation(
1368                tagged_beef,
1369                mode,
1370                &TopicAdmittanceContext::DRY_RUN,
1371                None,
1372                None,
1373                WalkBreachPolicy::NotNow,
1374            )
1375            .await?;
1376        Ok(steak)
1377    }
1378
1379    /// Submit a transaction for processing by the overlay.
1380    ///
1381    /// Three phases:
1382    /// 1. **VALIDATE** — check topics, dedup, find previous coins, call topic managers
1383    /// 2. **BROADCAST** — broadcast to network (if mode is CurrentTx)
1384    /// 3. **MUTATE** — mark spent, delete stale, insert new outputs, notify lookup services
1385    ///
1386    /// Returns a STEAK mapping each topic to its admittance instructions.
1387    ///
1388    /// Phase-3 faults are reported through [`Engine::submit_with_report`];
1389    /// this convenience keeps the historical signature and only LOGS the
1390    /// report's summary. A caller that must not ack an admission whose
1391    /// writes did not land (the `/submit` route — S2) uses the report form.
1392    pub async fn submit(
1393        &self,
1394        tagged_beef: &TaggedBEEF,
1395        mode: SubmitMode,
1396    ) -> Result<Steak, EngineError> {
1397        let (steak, report) = self.submit_with_report(tagged_beef, mode).await?;
1398        if !report.is_durable() {
1399            warn!(
1400                "submit: {} mutation fault(s) — not durable: {}",
1401                report.faults.len(),
1402                report.summary()
1403            );
1404        }
1405        Ok(steak)
1406    }
1407
1408    /// [`Engine::submit`] plus the Phase-3 durability report (S2
1409    /// queue-durable admission, bsv-low 2026-08-29).
1410    ///
1411    /// The STEAK is the admission DECISION; the report says whether the
1412    /// decision was DURABLY WRITTEN. They are returned together because
1413    /// they are answered by different things: Phase 1+2 decide, Phase 3
1414    /// lands, and only the caller knows whether an undurable landing may
1415    /// be acked (never, on the money path — re-queue it instead).
1416    ///
1417    /// Every write failure is still logged AND still non-fatal to the
1418    /// other topics (a faulted topic does not un-admit its siblings); what
1419    /// changed is that the failure is also VISIBLE, and that the faulted
1420    /// topic is not recorded as applied — so a replay of the same bytes is
1421    /// re-validated and re-written (every backend write is idempotent:
1422    /// `INSERT OR IGNORE` / `OR REPLACE` / `UPDATE`).
1423    ///
1424    /// What a fault leaves (bsv-low #559 and its lens fold of 2026-10-07):
1425    /// a topic's previous coins are deleted once EVERY admitted output of
1426    /// the transaction is inserted, and only then. A fault before that
1427    /// (a validation read, the spent mark of a coin the manager retains, an
1428    /// insert) leaves the previous coins held and nothing of the
1429    /// transaction: what did get inserted is taken out again, so the replay
1430    /// is the same judgement and no successor can spend half of it. A fault
1431    /// after it (a lookup notification, the consumed-by update, the applied
1432    /// row) leaves the transaction's outputs and no previous coin. A fault
1433    /// before any delete was started (the record of the spent coin's
1434    /// transaction, the read of the coin) is the first case if the coins
1435    /// are read back still held; once a delete was started its fault never
1436    /// undoes, answered or not (it may land yet): the outputs stay beside
1437    /// the stale coin, marked spent. A submit that deletes a stale coin
1438    /// first records that coin's transaction as applied, so a replay of it
1439    /// after its successor is a dupe. So a non-retaining chain never has
1440    /// two unspent heads and never none, whatever single call faults. The
1441    /// applied row is withheld on ANY fault. And a successor that was
1442    /// judged without the coin an unlanded predecessor has yet to leave is
1443    /// reported as a fault (`predecessor_not_landed`), not recorded as
1444    /// applied.
1445    ///
1446    /// A walk that could not run within the engine's budget is "not now"
1447    /// here ([`WalkBreachPolicy::NotNow`], [`EngineError::WalkCouldNotRun`]):
1448    /// never an admission. [`Engine::submit_with_report_under`] names the
1449    /// policy.
1450    pub async fn submit_with_report(
1451        &self,
1452        tagged_beef: &TaggedBEEF,
1453        mode: SubmitMode,
1454    ) -> Result<(Steak, MutationReport), EngineError> {
1455        self.submit_with_report_under(tagged_beef, mode, WalkBreachPolicy::NotNow)
1456            .await
1457    }
1458
1459    /// [`Engine::submit_with_report`] under an explicit
1460    /// [`WalkBreachPolicy`] (the E592 lens fold, H1): what a walk that could
1461    /// not run within the budget does. `NetworkAccepted` is for a caller that
1462    /// holds the network's accept of these very bytes (the queue's replay of
1463    /// a gated submission) and for nothing else: it admits a breached body
1464    /// with its scripts unrun.
1465    pub async fn submit_with_report_under(
1466        &self,
1467        tagged_beef: &TaggedBEEF,
1468        mode: SubmitMode,
1469        policy: WalkBreachPolicy,
1470    ) -> Result<(Steak, MutationReport), EngineError> {
1471        self.submit_bounded(tagged_beef, mode, None, false, policy)
1472            .await
1473    }
1474
1475    /// [`Engine::submit_with_report`] with every storage call, lookup hook
1476    /// and topic manager call under `bound` (the GASP finalize submit,
1477    /// [`CallBound`]); `None` is the unbounded submit of every other door.
1478    /// `finalize` is `true` for the GASP finalize submit alone, bounded or
1479    /// not: it does not ask the store's predecessor question.
1480    async fn submit_bounded(
1481        &self,
1482        tagged_beef: &TaggedBEEF,
1483        mode: SubmitMode,
1484        bound: Option<&CallBound>,
1485        finalize: bool,
1486        policy: WalkBreachPolicy,
1487    ) -> Result<(Steak, MutationReport), EngineError> {
1488        // The reads the store's predecessor question has made in this
1489        // submit, every topic together ([`PREDECESSOR_READS_PER_SUBMIT`]),
1490        // the predecessors the door lands first from the BEEF included.
1491        let mut question_reads = 0usize;
1492        self.submit_counted(
1493            tagged_beef,
1494            mode,
1495            bound,
1496            finalize,
1497            None,
1498            &mut question_reads,
1499            policy,
1500        )
1501        .await
1502        .map(|(steak, report, _)| (steak, report))
1503    }
1504
1505    /// [`Engine::submit_bounded`] over the submit's read allowance
1506    /// (`question_reads`), which a predecessor the door lands first from the
1507    /// BEEF shares with the successor that carried it (lane E1D's lens fold,
1508    /// M1). `carried` is `Some` for such a predecessor's own submit
1509    /// ([`Engine::land_carried`]), holding the bodies whose SPV walk already
1510    /// passed in this submit (none is walked twice; the delta fold, L4): it
1511    /// lands nothing first itself, and it names instead the carried
1512    /// predecessor that blocks each topic (the third value), so the landing
1513    /// is an explicit stack and never a recursion of submits (nested polls
1514    /// grow the stack, and a Worker's is small). Its manager is asked as a
1515    /// DRY RUN first, and for real only once its topic is not blocked: a
1516    /// link blocked below is judged twice and admitted once. Boxed: the
1517    /// landing is a submit of its own.
1518    #[allow(clippy::too_many_arguments)]
1519    fn submit_counted<'a>(
1520        &'a self,
1521        tagged_beef: &'a TaggedBEEF,
1522        mode: SubmitMode,
1523        bound: Option<&'a CallBound>,
1524        finalize: bool,
1525        carried: Option<&'a HashSet<String>>,
1526        question_reads: &'a mut usize,
1527        policy: WalkBreachPolicy,
1528    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Counted, EngineError>> + 'a>>
1529    {
1530        Box::pin(async move {
1531            self.submit_counted_inner(
1532                tagged_beef,
1533                mode,
1534                bound,
1535                finalize,
1536                carried,
1537                question_reads,
1538                policy,
1539            )
1540            .await
1541        })
1542    }
1543
1544    #[allow(clippy::too_many_arguments)]
1545    async fn submit_counted_inner(
1546        &self,
1547        tagged_beef: &TaggedBEEF,
1548        mode: SubmitMode,
1549        bound: Option<&CallBound>,
1550        finalize: bool,
1551        walked: Option<&HashSet<String>>,
1552        question_reads: &mut usize,
1553        policy: WalkBreachPolicy,
1554    ) -> Result<Counted, EngineError> {
1555        // A submit is a real admission, never a dry run; a carried
1556        // predecessor's is judged dry until its topic is known not to wait.
1557        let carried = walked.is_some();
1558        let context = if carried {
1559            TopicAdmittanceContext::DRY_RUN
1560        } else {
1561            TopicAdmittanceContext::default()
1562        };
1563        let (mut validations, mut steak, tx, txid, could_not_run) = self
1564            .run_validation(tagged_beef, mode, &context, bound, walked, policy)
1565            .await?;
1566        if finalize {
1567            let now = (current_timestamp_ms() / 1000.0) as u64;
1568            for validation in &mut validations {
1569                let manager = &self.managers[&validation.topic];
1570                validation.admittance.outputs_to_admit.retain(|index| {
1571                    tx.outputs.get(*index as usize).is_none_or(|output| {
1572                        manager
1573                            .sync_expiry(&output.locking_script)
1574                            .is_none_or(|expiry| expiry > now)
1575                    })
1576                });
1577                steak.insert(validation.topic.clone(), validation.admittance.clone());
1578            }
1579        }
1580        let mut report = MutationReport {
1581            walk_could_not_run: could_not_run,
1582            ..MutationReport::default()
1583        };
1584        // The body every LOOKUP SERVICE receives NAMES the subject (BRC-95
1585        // atomic prefix over the WHOLE submitted body — bsv-rs's
1586        // `to_binary_atomic` sorts, names, never prunes). Lookup services
1587        // re-parse it with `from_beef(_, None)`; on a plain incomplete body
1588        // that pick is the wire-last ANCESTOR, so the pot index's admit hook
1589        // shape-checked a hop and wrote no record while the engine had
1590        // admitted the pot (loop-2 fleet, 2026-09-05; the live proof after
1591        // the engine's own subject fix). A body that cannot be re-serialized
1592        // (never, for a parsed subject) is handed through as submitted.
1593        let subject_named_beef: Vec<u8> =
1594            beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
1595                .ok()
1596                .and_then(|mut b| b.to_binary_atomic(&txid).ok())
1597                .unwrap_or_else(|| tagged_beef.beef.clone());
1598
1599        // The successor rule, asked of every topic that found no coin before
1600        // anything of this submit is written ([`Engine::successors_waiting`]):
1601        // the topics that must wait, each with why.
1602        let mut blocked: HashMap<String, String> = HashMap::new();
1603        // The landing trusts the subject's walk only if it RAN (the E592
1604        // lens fold, M1): after a breach nothing it reaches was walked.
1605        let landing = Landing {
1606            policy,
1607            subject_walk_ran: report.walk_could_not_run.is_none(),
1608        };
1609        let waits = self
1610            .successors_waiting(
1611                &tx,
1612                &txid,
1613                &subject_named_beef,
1614                &mut validations,
1615                &mut steak,
1616                tagged_beef.off_chain_values.as_deref(),
1617                mode,
1618                bound,
1619                finalize,
1620                !carried,
1621                &mut blocked,
1622                question_reads,
1623                &mut report.landed_predecessors,
1624                landing,
1625                &mut report.landed_walks_could_not_run,
1626            )
1627            .await;
1628        // A carried predecessor's topic that does not wait is judged again,
1629        // for real: what it admits is this call's word, over the same coins.
1630        if carried {
1631            for v in &mut validations {
1632                if v.is_dupe || v.failed || v.read_fault.is_some() || waits.contains_key(&v.topic) {
1633                    continue;
1634                }
1635                match self
1636                    .judge(
1637                        &tx,
1638                        &v.topic,
1639                        &v.previous_coins,
1640                        tagged_beef.off_chain_values.as_deref(),
1641                        mode,
1642                        &TopicAdmittanceContext::default(),
1643                        bound,
1644                    )
1645                    .await
1646                {
1647                    Ok(admittance) => v.admittance = admittance,
1648                    Err(e) => {
1649                        error!("Error validating topic {} during submit: {e}", v.topic);
1650                        v.failed = true;
1651                        v.previous_coins.clear();
1652                        v.previous_outputs.clear();
1653                        v.admittance = AdmittanceInstructions::default();
1654                    }
1655                }
1656                steak.insert(v.topic.clone(), v.admittance.clone());
1657            }
1658        }
1659
1660        // =================================================================
1661        // PHASE 3: MUTATE STORAGE
1662        // =================================================================
1663        for v in &validations {
1664            if v.is_dupe {
1665                report.deduped_topics.push(v.topic.clone());
1666                continue;
1667            }
1668            // A failed topic comes first, a read fault of it included: a
1669            // previous-coin read that faulted and a manager that then ERRED
1670            // is a failed topic with a durable report, no fault and no
1671            // replay. Parity: the reference fails the topic and never
1672            // replays. Nothing was written, so the store is as it was.
1673            if v.failed {
1674                continue;
1675            }
1676
1677            let topic = &v.topic;
1678            let admittance = &v.admittance;
1679            let faults_before = report.faults.len();
1680
1681            // A validation read that faulted: the manager judged without a
1682            // coin the store may hold, so NOTHING is written on that
1683            // judgement (the lens fold of 2026-10-07). Writing its outputs
1684            // would leave them beside the unseen coin, for a successor to
1685            // spend before the replay deletes it.
1686            if let Some((read, err)) = &v.read_fault {
1687                report.fault(topic, read, err.clone());
1688                self.hold_unapplied(&txid, topic, "a validation read faulted, nothing written");
1689                continue;
1690            }
1691
1692            // bsv-low #559 and lane E1D: a topic that found no coin while a
1693            // predecessor's landing is unknown is "not now", recorded
1694            // nowhere ([`Engine::successors_waiting`]).
1695            if let Some(not_now) = waits.get(topic) {
1696                report.fault(topic, "predecessor_not_landed", not_now.clone());
1697                self.hold_unapplied(
1698                    &txid,
1699                    topic,
1700                    &format!("found no coin and its predecessor {not_now}"),
1701                );
1702                continue;
1703            }
1704
1705            // ── Handle stale vs retained previous coins ──
1706            let mut outputs_consumed: Vec<Outpoint> = Vec::new();
1707            let mut stale_coins: Vec<&Output> = Vec::new();
1708
1709            for (coin_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1710                let input_index = v.previous_coins[coin_idx];
1711                if admittance.coins_to_retain.contains(&input_index) {
1712                    // Retained: track as consumed (will update consumedBy later)
1713                    outputs_consumed
1714                        .push(Outpoint::new(&prev_output.txid, prev_output.output_index));
1715                } else {
1716                    // Not retained: mark as stale for deletion
1717                    stale_coins.push(prev_output);
1718                }
1719            }
1720
1721            // Update STEAK with removed coins
1722            if let Some(steak_entry) = steak.get_mut(topic) {
1723                steak_entry.coins_removed = Some(
1724                    stale_coins
1725                        .iter()
1726                        .enumerate()
1727                        .filter_map(|(i, _)| {
1728                            // Map back to input indices
1729                            v.previous_coins.get(i).copied()
1730                        })
1731                        .collect(),
1732                );
1733            }
1734
1735            // ── Mark previous outputs as spent + notify lookup services ──
1736            // A mark that faults does not skip the notification (the
1737            // reference skips it): the coin is spent whatever the row says,
1738            // a stale one is deleted below and its lookup services must
1739            // know. The mark of a RETAINED coin is part of what landed: that
1740            // row stays, and left unspent it would be listed as a UTXO
1741            // beside the output that consumed it.
1742            let mut retained_mark_faulted = false;
1743            for (prev_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1744                if let Err(e) = stored(
1745                    bound,
1746                    self.storage.mark_utxo_as_spent(
1747                        &prev_output.txid,
1748                        prev_output.output_index,
1749                        topic,
1750                    ),
1751                )
1752                .await
1753                {
1754                    error!("Error marking UTXO as spent: {e}");
1755                    report.fault(topic, "mark_utxo_as_spent", e.to_string());
1756                    retained_mark_faulted |= admittance
1757                        .coins_to_retain
1758                        .contains(&v.previous_coins[prev_idx]);
1759                }
1760
1761                // The input index within tx.inputs that spends this previous output.
1762                let spending_input_idx = v.previous_coins[prev_idx] as usize;
1763
1764                // Notify all lookup services about the spent output
1765                for ls in self.lookup_services.values() {
1766                    let payload = match ls.spend_notification_mode() {
1767                        SpendNotificationMode::None => OutputSpent::None {
1768                            txid: prev_output.txid.clone(),
1769                            output_index: prev_output.output_index,
1770                            topic: topic.clone(),
1771                        },
1772                        SpendNotificationMode::Txid => OutputSpent::Txid {
1773                            txid: prev_output.txid.clone(),
1774                            output_index: prev_output.output_index,
1775                            topic: topic.clone(),
1776                            spending_txid: txid.clone(),
1777                        },
1778                        SpendNotificationMode::Script => {
1779                            // Extract unlocking script and sequence from the spending input
1780                            let input = &tx.inputs[spending_input_idx];
1781                            let unlocking_script = input
1782                                .unlocking_script
1783                                .as_ref()
1784                                .map(bsv_rs::UnlockingScript::to_binary)
1785                                .unwrap_or_default();
1786                            OutputSpent::Script {
1787                                txid: prev_output.txid.clone(),
1788                                output_index: prev_output.output_index,
1789                                topic: topic.clone(),
1790                                spending_txid: txid.clone(),
1791                                input_index: spending_input_idx as u32,
1792                                unlocking_script,
1793                                sequence_number: input.sequence,
1794                                off_chain_values: tagged_beef.off_chain_values.clone(),
1795                            }
1796                        }
1797                        SpendNotificationMode::WholeTx => OutputSpent::WholeTx {
1798                            txid: prev_output.txid.clone(),
1799                            output_index: prev_output.output_index,
1800                            topic: topic.clone(),
1801                            spending_atomic_beef: subject_named_beef.clone(),
1802                            off_chain_values: tagged_beef.off_chain_values.clone(),
1803                        },
1804                    };
1805                    if let Err(e) = hooked(bound, ls.output_spent(&payload)).await {
1806                        error!("Error notifying lookup service of spent output: {e}");
1807                        report.fault(topic, "lookup_service.output_spent", e);
1808                    }
1809                }
1810            }
1811            if retained_mark_faulted {
1812                self.hold_unapplied(
1813                    &txid,
1814                    topic,
1815                    "the spent mark of a retained coin faulted, nothing inserted",
1816                );
1817                continue;
1818            }
1819
1820            // ── Insert admitted outputs ──
1821            // The inserts stop at the first one that faults: what follows is
1822            // the undo, not the rest of the transaction.
1823            let mut new_utxos: Vec<Outpoint> = Vec::new();
1824            let mut attempted: Vec<u32> = Vec::new();
1825            let mut insert_faulted = false;
1826
1827            for &output_index in &admittance.outputs_to_admit {
1828                let (script_bytes, sats) =
1829                    if let Some(tx_output) = tx.outputs.get(output_index as usize) {
1830                        (
1831                            tx_output.locking_script.to_binary(),
1832                            tx_output.get_satoshis(),
1833                        )
1834                    } else {
1835                        (Vec::new(), 0)
1836                    };
1837
1838                let output = Output {
1839                    txid: txid.clone(),
1840                    output_index,
1841                    output_script: script_bytes,
1842                    satoshis: sats,
1843                    topic: topic.clone(),
1844                    spent: false,
1845                    outputs_consumed: outputs_consumed.clone(),
1846                    consumed_by: Vec::new(),
1847                    beef: Some(tagged_beef.beef.clone()),
1848                    block_height: None,
1849                    score: Some(current_timestamp_ms()),
1850                };
1851
1852                attempted.push(output_index);
1853                if let Err(e) = stored(
1854                    bound,
1855                    self.storage.insert_output_with_origin(&output, finalize),
1856                )
1857                .await
1858                {
1859                    error!("Error inserting output for topic {topic}: {e}");
1860                    report.fault(topic, "insert_output", e.to_string());
1861                    insert_faulted = true;
1862                    break;
1863                }
1864
1865                new_utxos.push(Outpoint::new(&txid, output_index));
1866
1867                // Notify lookup services
1868                for ls in self.lookup_services.values() {
1869                    let (ls_script, ls_sats) =
1870                        if let Some(tx_out) = tx.outputs.get(output_index as usize) {
1871                            (tx_out.locking_script.to_binary(), tx_out.get_satoshis())
1872                        } else {
1873                            (Vec::new(), 0)
1874                        };
1875
1876                    let payload = match ls.admission_mode() {
1877                        AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
1878                            txid: txid.clone(),
1879                            output_index,
1880                            topic: topic.clone(),
1881                            satoshis: ls_sats,
1882                            locking_script: ls_script,
1883                            off_chain_values: tagged_beef.off_chain_values.clone(),
1884                        },
1885                        AdmissionMode::WholeTx => OutputAdmittedByTopic::WholeTx {
1886                            atomic_beef: subject_named_beef.clone(),
1887                            output_index,
1888                            topic: topic.clone(),
1889                            off_chain_values: tagged_beef.off_chain_values.clone(),
1890                        },
1891                    };
1892
1893                    if let Err(e) = hooked(bound, ls.output_admitted_by_topic(&payload)).await {
1894                        error!("Error notifying lookup service: {e}");
1895                        report.fault(topic, "lookup_service.output_admitted_by_topic", e);
1896                    }
1897                }
1898            }
1899
1900            // An insert faulted: the transaction did not land, so NOTHING of
1901            // it stays (the lens fold of 2026-10-07, F1). The previous coins
1902            // are kept (#559: with the delete first, one faulted insert left
1903            // a non-retaining head chain with NO head) and the outputs that
1904            // were inserted are taken out again, the faulted one included (a
1905            // backend's insert can fault after its row landed). Left in,
1906            // they could be spent and deleted by a successor before the
1907            // replay, and the replay, which still finds the previous coin,
1908            // would insert them a second time: a spent head back among the
1909            // UTXOs for good.
1910            if insert_faulted {
1911                if let Some(bound) = bound {
1912                    bound.renew();
1913                }
1914                self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
1915                    .await;
1916                self.hold_unapplied(
1917                    &txid,
1918                    topic,
1919                    &format!(
1920                        "an insert faulted: {} previous coin(s) kept, nothing of it held",
1921                        stale_coins.len()
1922                    ),
1923                );
1924                continue;
1925            }
1926
1927            // ── Delete stale outputs recursively ──
1928            // AFTER the inserts, and once every one of them landed (bsv-low
1929            // #559 and the lens fold of 2026-10-07, F1). The reference
1930            // deletes first (`applyTopicStorageMutation`: `removeStaleOutputs`,
1931            // then `admitOutput`), catches a throw per topic and never
1932            // replays; here a fault is survived and replayed, so the order
1933            // is ours, an addition. The delete does NOT wait for a clean
1934            // report (c9921ee did, and one faulted mark or notification
1935            // after the insert then left the stale coin beside the new
1936            // output for good: two unspent heads): a fault of the mark, of
1937            // a notification, deletes as the reference does.
1938            //
1939            // Before a stale coin is deleted its transaction is recorded as
1940            // applied in the topic (the delta fold of 2026-10-07, H2; an
1941            // addition, one idempotent write per deleted coin). A held coin
1942            // proves the inserts of its transaction landed, but that
1943            // transaction may have no applied row (a fault after its
1944            // inserts withheld it, or its insert landed after its timeout),
1945            // and then its replay is judged again: a manager that admits it
1946            // with NO previous coin (the opener of a chain) had it inserted
1947            // a second time, unspent, beside the output that spent it, for
1948            // good. With the row its replay is a dupe. A record that faults
1949            // leaves its coin undeleted: that is a delete fault, below.
1950            let due_before_the_delete = bound.is_some_and(CallBound::is_due);
1951            let mut delete_faulted = false;
1952            let mut delete_started = false;
1953            for stale in &stale_coins {
1954                let spent_coin_tx = AppliedTransaction {
1955                    txid: stale.txid.clone(),
1956                    topic: topic.clone(),
1957                };
1958                if let Err(e) = stored(
1959                    bound,
1960                    self.storage.insert_applied_transaction(&spent_coin_tx),
1961                )
1962                .await
1963                {
1964                    error!(
1965                        "Error recording {} as applied before its coin is deleted from topic {topic}: {e}",
1966                        stale.txid
1967                    );
1968                    report.fault(topic, "record_spent_coin_applied", e.to_string());
1969                    delete_faulted = true;
1970                    continue;
1971                }
1972                self.not_landed
1973                    .borrow_mut()
1974                    .remove(&(stale.txid.clone(), topic.clone()));
1975                match stored(
1976                    bound,
1977                    self.storage.find_output(
1978                        &stale.txid,
1979                        stale.output_index,
1980                        Some(topic),
1981                        None,
1982                        false,
1983                    ),
1984                )
1985                .await
1986                {
1987                    Ok(Some(stale_output)) => {
1988                        delete_started = true;
1989                        if let Err(e) = self.delete_utxo_deep(&stale_output, bound).await {
1990                            error!("Error deleting stale output for topic {topic}: {e}");
1991                            report.fault(topic, "delete_utxo_deep", e.to_string());
1992                            delete_faulted = true;
1993                        }
1994                    }
1995                    Ok(None) => {}
1996                    Err(e) => {
1997                        error!("Error reading stale output for topic {topic}: {e}");
1998                        report.fault(topic, "find_output", e.to_string());
1999                        delete_faulted = true;
2000                    }
2001                }
2002            }
2003
2004            // A fault here, and what it leaves. The store is put back as it
2005            // was found (the inserts undone, the replay does the whole
2006            // transaction) only when NO delete was started: the fault is of
2007            // H2's record or of the read before the delete, the call
2008            // answered, and EVERY stale coin is read back still held.
2009            //
2010            // Once a delete was STARTED nothing is undone, whether it
2011            // answered or not (the delta fold of 2026-10-07, H1, for a call
2012            // that did not answer; the second delta fold, M1, for one that
2013            // answered an error). A statement can land after its caller was
2014            // told it failed, as after its caller stopped waiting: the coin
2015            // is read back held now and is deleted a moment later, and the
2016            // undo then left the chain with no head, for good, for a manager
2017            // that needs the previous coin. So the outputs stay, the stale
2018            // coin stays (marked spent) until its delete lands or is done
2019            // again, and there is no applied row. Since H2 every order from
2020            // there converges. The replay first, the delete never landed:
2021            // the coin is found, the manager admits again, the inserts are
2022            // no-ops, the delete is done and the row written. The replay
2023            // first, the delete landed late: no previous coin, so a manager
2024            // that needs one admits nothing and the transaction is recorded
2025            // with its outputs held. A successor first: it recorded this
2026            // transaction as applied (H2) and the replay is a dupe; the
2027            // stale coin's spent row is then left behind, no UTXO.
2028            //
2029            // A call that did not ANSWER before any delete (the record, the
2030            // read) undoes nothing either: it is not known what it did. A
2031            // deadline that was already due BEFORE the first of these calls
2032            // started nothing here, so the coins are certainly held and the
2033            // store is put back.
2034            if delete_faulted {
2035                let unanswered = !due_before_the_delete && bound.is_some_and(CallBound::is_due);
2036                if let Some(bound) = bound {
2037                    bound.renew();
2038                }
2039                let mut all_held = !unanswered && !delete_started;
2040                for stale in &stale_coins {
2041                    if !all_held {
2042                        break;
2043                    }
2044                    let read = stored(
2045                        bound,
2046                        self.storage.find_output(
2047                            &stale.txid,
2048                            stale.output_index,
2049                            Some(topic),
2050                            None,
2051                            false,
2052                        ),
2053                    )
2054                    .await;
2055                    if !matches!(read, Ok(Some(_))) {
2056                        all_held = false;
2057                        break;
2058                    }
2059                }
2060                if all_held {
2061                    self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
2062                        .await;
2063                }
2064                self.hold_unapplied(
2065                    &txid,
2066                    topic,
2067                    if all_held {
2068                        "no delete of a stale coin was started: the coins are held, its outputs taken out"
2069                    } else if unanswered {
2070                        "the delete of a stale coin did not answer: nothing undone, its outputs stay"
2071                    } else if delete_started {
2072                        "the delete of a stale coin faulted: nothing undone, its outputs stay"
2073                    } else {
2074                        "no delete was started and a stale coin was not read back held: nothing undone, its outputs stay"
2075                    },
2076                );
2077                continue;
2078            }
2079
2080            // ── Update consumedBy on retained previous outputs ──
2081            for consumed_outpoint in &outputs_consumed {
2082                let consumed_output = match stored(
2083                    bound,
2084                    self.storage.find_output(
2085                        &consumed_outpoint.txid,
2086                        consumed_outpoint.output_index,
2087                        Some(topic),
2088                        None,
2089                        false,
2090                    ),
2091                )
2092                .await
2093                {
2094                    Ok(Some(o)) => o,
2095                    Ok(None) => continue,
2096                    Err(e) => {
2097                        error!("Error reading consumed output for topic {topic}: {e}");
2098                        report.fault(topic, "find_output", e.to_string());
2099                        continue;
2100                    }
2101                };
2102                {
2103                    let mut new_consumed_by = consumed_output.consumed_by.clone();
2104                    for new_utxo in &new_utxos {
2105                        if !new_consumed_by.iter().any(|c| {
2106                            c.txid == new_utxo.txid && c.output_index == new_utxo.output_index
2107                        }) {
2108                            new_consumed_by.push(new_utxo.clone());
2109                        }
2110                    }
2111                    if let Err(e) = stored(
2112                        bound,
2113                        self.storage.update_consumed_by(
2114                            &consumed_outpoint.txid,
2115                            consumed_outpoint.output_index,
2116                            topic,
2117                            &new_consumed_by,
2118                        ),
2119                    )
2120                    .await
2121                    {
2122                        error!("Error updating consumedBy: {e}");
2123                        report.fault(topic, "update_consumed_by", e.to_string());
2124                    }
2125                }
2126            }
2127
2128            // Record applied transaction — ONLY when every write above
2129            // landed. A faulted topic stays unrecorded so the Phase-1 dedup
2130            // cannot turn its replay into a no-op (S2).
2131            if report.faults.len() > faults_before {
2132                self.hold_unapplied(
2133                    &txid,
2134                    topic,
2135                    &format!(
2136                        "{} mutation fault(s); a replay of these bytes re-applies",
2137                        report.faults.len() - faults_before
2138                    ),
2139                );
2140                continue;
2141            }
2142            let tx_record = AppliedTransaction {
2143                txid: txid.clone(),
2144                topic: topic.clone(),
2145            };
2146            match stored(bound, self.storage.insert_applied_transaction(&tx_record)).await {
2147                Ok(()) => {
2148                    report.applied_topics.push(topic.clone());
2149                    self.not_landed
2150                        .borrow_mut()
2151                        .remove(&(txid.clone(), topic.clone()));
2152                }
2153                Err(e) => {
2154                    error!("Error inserting applied transaction for topic {topic}: {e}");
2155                    report.fault(topic, "insert_applied_transaction", e.to_string());
2156                    self.not_landed
2157                        .borrow_mut()
2158                        .insert((txid.clone(), topic.clone()));
2159                }
2160            }
2161        }
2162
2163        Ok((steak, report, blocked))
2164    }
2165
2166    /// The successor rule (bsv-low #559; lane E1D, bsv-low #575; and its
2167    /// lens fold of 2026-10-08), asked of every topic of a submit that found
2168    /// NO previous coin while the transaction spends something, before
2169    /// anything of the submit is written. Returns the topics that must wait,
2170    /// each with why: those are reported as a fault (`predecessor_not_landed`)
2171    /// and recorded nowhere, "not now". An addition to the reference, which
2172    /// records every non-failed topic.
2173    ///
2174    /// Such a topic was judged without the coin a predecessor has yet to
2175    /// leave. Recorded as applied, its replay was a dupe for good: a
2176    /// transaction that admits nothing ended the chain at the predecessor
2177    /// (#559), and one the manager admits WITHOUT its coin (the pf head
2178    /// manager over its own head state, E1D) was inserted, and the
2179    /// predecessor, landing later, stood unspent beside the tip.
2180    ///
2181    /// "Has not landed" is known two ways: this engine saw it fault
2182    /// (`not_landed`, one invocation, every door), or the store says so
2183    /// ([`Engine::unlanded_predecessor`], any invocation, never at a GASP
2184    /// finalize submit: its graph passed the anchor check, every parent
2185    /// inside the graph was submitted just before it and the sequence stops
2186    /// at the first that does not land; the second delta fold of 2026-10-07,
2187    /// M2). For a successor that admits something, both look only at the
2188    /// inputs the manager NAMES as its history (`identify_needed_inputs`
2189    /// over the subject-named BEEF; the lens fold, L1): a manager that
2190    /// names nothing (all 16 of this workspace) asks nothing there, as in
2191    /// the reference. A successor that admits NOTHING is asked of every
2192    /// input whatever its manager names (D17 (a)), so under such a manager
2193    /// it still waits for, or lands first, a carried body that spends a
2194    /// coin the topic holds: there the store differs from the reference's
2195    /// (the E1D delta fold, L3).
2196    ///
2197    /// The door lands a carried predecessor FIRST (the lens fold, M1; the
2198    /// GASP walk's rule, at the door): when the question names an unlanded
2199    /// predecessor whose body the BEEF carries, that body is submitted on
2200    /// its own to this topic ([`Engine::land_carried`]), under the submit's
2201    /// read allowance, and once it lands the topic is judged again
2202    /// ([`Engine::validate_topic`]) and asked again. The re-judgement's
2203    /// reads (the applied row, one coin per input) are charged to that
2204    /// allowance BEFORE the landing (the E1D delta fold, M1): a landing whose
2205    /// re-judgement would not fit is not made, and the topic waits. So one
2206    /// submit reads at most its own validation and writes plus
2207    /// [`PREDECESSOR_READS_PER_SUBMIT`], landings (on their clean path) and
2208    /// re-judgements included; a faulted landing's read-backs and undo, and
2209    /// a deep delete's walk of retained history, are not charged.
2210    /// Nothing is landed into a topic whose manager or a lookup
2211    /// service reads off-chain values (the delta fold, L1: the landing has
2212    /// none to give, and the predecessor's own submit would be a dupe): the
2213    /// topic waits for that submit. The first predecessor that does not land
2214    /// ends it: the topic waits. A predecessor whose body the BEEF does not
2215    /// carry is never landed here: its successor converges only when it
2216    /// lands (its own replay, a GASP peer, a resubmit).
2217    #[allow(clippy::too_many_arguments)]
2218    async fn successors_waiting(
2219        &self,
2220        tx: &Transaction,
2221        txid: &str,
2222        subject_named_beef: &[u8],
2223        validations: &mut [TopicValidation],
2224        steak: &mut Steak,
2225        off_chain_values: Option<&[u8]>,
2226        mode: SubmitMode,
2227        bound: Option<&CallBound>,
2228        finalize: bool,
2229        lands: bool,
2230        blocked: &mut HashMap<String, String>,
2231        question_reads: &mut usize,
2232        landed: &mut Vec<(String, String)>,
2233        landing: Landing,
2234        landed_breaches: &mut Vec<WalkCouldNotRun>,
2235    ) -> HashMap<String, String> {
2236        let mut waits = HashMap::new();
2237        let spent: Vec<(String, u32)> = tx
2238            .inputs
2239            .iter()
2240            .filter_map(|input| {
2241                let source = input.get_source_txid().ok()?;
2242                (!source.is_empty()).then_some((source, input.source_output_index))
2243            })
2244            .collect();
2245        if spent.is_empty() {
2246            return waits;
2247        }
2248        let found_no_coin = |v: &TopicValidation| {
2249            !v.is_dupe && !v.failed && v.read_fault.is_none() && v.previous_outputs.is_empty()
2250        };
2251        for v in validations.iter_mut() {
2252            if !found_no_coin(v) {
2253                continue;
2254            }
2255            let Some(manager) = self.managers.get(&v.topic) else {
2256                continue;
2257            };
2258            let topic = v.topic.clone();
2259            // The manager's word on the subject's history, asked at most
2260            // once per topic: up front for a successor that admits, by the
2261            // question when it needs it for one that does not.
2262            let mut subject_names: Option<HashSet<String>> = None;
2263            loop {
2264                let admits = !v.admittance.outputs_to_admit.is_empty();
2265                if admits && subject_names.is_none() {
2266                    subject_names = Some(
2267                        self.named_history(manager.as_ref(), subject_named_beef, bound)
2268                            .await,
2269                    );
2270                }
2271                let remembered = spent.iter().find(|(source, vout)| {
2272                    (!admits
2273                        || subject_names
2274                            .as_ref()
2275                            .is_some_and(|named| named.contains(&format!("{source}.{vout}"))))
2276                        && self
2277                            .not_landed
2278                            .borrow()
2279                            .contains(&(source.clone(), topic.clone()))
2280                });
2281                if let Some((predecessor, _)) = remembered {
2282                    waits.insert(topic.clone(), format!("{predecessor} has not landed"));
2283                    break;
2284                }
2285                if finalize {
2286                    break;
2287                }
2288                let Some(not_now) = self
2289                    .unlanded_predecessor(
2290                        tx,
2291                        txid,
2292                        subject_named_beef,
2293                        &topic,
2294                        mode,
2295                        admits,
2296                        &mut subject_names,
2297                        question_reads,
2298                        bound,
2299                    )
2300                    .await
2301                else {
2302                    break;
2303                };
2304                let Some(carried) = not_now.carried else {
2305                    waits.insert(topic.clone(), not_now.why);
2306                    break;
2307                };
2308                if !lands {
2309                    blocked.insert(topic.clone(), carried);
2310                    waits.insert(topic.clone(), not_now.why);
2311                    break;
2312                }
2313                if !self.lands_without_values(&topic) {
2314                    waits.insert(
2315                        topic.clone(),
2316                        format!(
2317                            "{}; not landed first from the BEEF: the topic's manager or a lookup service reads off-chain values, and only the predecessor's own submit carries them",
2318                            not_now.why
2319                        ),
2320                    );
2321                    break;
2322                }
2323                // The re-judgement after the landing, charged before it.
2324                let rejudge = 1 + spent.len();
2325                if *question_reads + rejudge > PREDECESSOR_READS_PER_SUBMIT {
2326                    waits.insert(
2327                        topic.clone(),
2328                        format!(
2329                            "{}; its landing and the re-judgement after it do not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads",
2330                            not_now.why
2331                        ),
2332                    );
2333                    break;
2334                }
2335                *question_reads += rejudge;
2336                match self
2337                    .land_carried(
2338                        subject_named_beef,
2339                        txid,
2340                        &carried,
2341                        &topic,
2342                        mode,
2343                        bound,
2344                        question_reads,
2345                        landed,
2346                        landing,
2347                        landed_breaches,
2348                    )
2349                    .await
2350                {
2351                    Ok(()) => {
2352                        *v = self
2353                            .validate_topic(
2354                                tx,
2355                                txid,
2356                                &topic,
2357                                off_chain_values,
2358                                mode,
2359                                &TopicAdmittanceContext::default(),
2360                                bound,
2361                            )
2362                            .await;
2363                        steak.insert(topic.clone(), v.admittance.clone());
2364                        if !found_no_coin(v) {
2365                            break;
2366                        }
2367                    }
2368                    Err(did_not_land) => {
2369                        // No re-judgement follows: its reads were not made.
2370                        *question_reads -= rejudge;
2371                        waits.insert(
2372                            topic.clone(),
2373                            format!(
2374                                "{}; submitted first from the BEEF, it did not land ({did_not_land})",
2375                                not_now.why
2376                            ),
2377                        );
2378                        break;
2379                    }
2380                }
2381            }
2382            // A subject that already HOLDS an output in the topic landed
2383            // there before (the leftover of its own earlier submit whose
2384            // delete was started and faulted, D17 M1: its outputs held, no
2385            // applied row), and that admission was judged with its coin.
2386            // Its replay finishes it and does not wait (the E1D lens fold,
2387            // L4: a proven head spend that names a decoy no peer serves was
2388            // "not now" on every replay, three and a dead letter). One read,
2389            // only on the way to "not now", inside the allowance.
2390            if !finalize
2391                && waits.contains_key(&topic)
2392                && *question_reads < PREDECESSOR_READS_PER_SUBMIT
2393            {
2394                *question_reads += 1;
2395                if let Ok(outputs) = stored(
2396                    bound,
2397                    self.storage.find_outputs_for_transaction(txid, false),
2398                )
2399                .await
2400                {
2401                    if outputs.iter().any(|o| o.topic == topic) {
2402                        info!("topic {topic}: {txid} holds its outputs already; its replay finishes it");
2403                        waits.remove(&topic);
2404                        blocked.remove(&topic);
2405                    }
2406                }
2407            }
2408        }
2409        waits
2410    }
2411
2412    /// Land an unlanded predecessor whose body the successor's BEEF carries
2413    /// (lane E1D's lens fold, M1): submit it on its own to `topic`, its
2414    /// atomic BEEF out of the successor's, no off-chain values (the caller
2415    /// lands nothing into a topic that reads them; the delta fold, L1),
2416    /// never a broadcast (`current-tx` is submitted as `historical-tx`: the
2417    /// successor's BEEF carries it to the network).
2418    ///
2419    /// Each body is WALKED once in this submit (the delta fold, L4): the
2420    /// successor's own SPV walk (`subject`, the same mode, tracker and
2421    /// switch) already passed over every body it reaches without crossing a
2422    /// proven one, so those are not walked again; a body reached only
2423    /// through a proven one (which the successor's walk does not descend)
2424    /// gets its own walk, trusting what this submit already walked, and so
2425    /// does nothing twice. A walk that refuses a body ends the landing.
2426    ///
2427    /// A predecessor whose own submit waits on a carried predecessor of ITS
2428    /// own goes on a stack under it: ancestors first, the walk's rule at
2429    /// the door. Its manager was asked only a DRY RUN then and nothing was
2430    /// written; once the one under it landed it is submitted again and
2431    /// admitted for real, once ([`Engine::submit_counted`]). Each submit's
2432    /// reads on its clean path (its applied row, one coin per input, and
2433    /// its writes' one read of each coin it spends) are charged to the
2434    /// submit's allowance before it starts, and its predecessor
2435    /// question shares that allowance, so the landings of one submit stop
2436    /// at [`PREDECESSOR_READS_PER_SUBMIT`] reads, however deep the chain.
2437    /// Each body that lands is pushed on `landed` (the report's
2438    /// `landed_predecessors`, for the caller's admission guard; the delta
2439    /// fold, L2). Each body is asked of the caller's landing guard first,
2440    /// once ([`Engine::set_landing_guard`]; the delta-2 fold, L2): a refusal
2441    /// ends the landing before that body is submitted.
2442    /// `Ok` once `predecessor` LANDED in `topic` (recorded there,
2443    /// now or before); otherwise why not, at the first that did not.
2444    #[allow(clippy::too_many_arguments)]
2445    async fn land_carried(
2446        &self,
2447        beef_bytes: &[u8],
2448        subject: &str,
2449        predecessor: &str,
2450        topic: &str,
2451        mode: SubmitMode,
2452        bound: Option<&CallBound>,
2453        question_reads: &mut usize,
2454        landed: &mut Vec<(String, String)>,
2455        landing: Landing,
2456        landed_breaches: &mut Vec<WalkCouldNotRun>,
2457    ) -> Result<(), String> {
2458        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
2459            .map_err(|e| format!("the BEEF could not be read again ({e})"))?;
2460        let mode = match mode {
2461            SubmitMode::CurrentTx => SubmitMode::HistoricalTx,
2462            other => other,
2463        };
2464        let walks = mode != SubmitMode::HistoricalTxNoSpv;
2465        // The trust set is what a walk that RAN covered (the E592 lens fold,
2466        // M1): a subject whose walk breached covered nothing, so each carried
2467        // predecessor is walked by its own submit (and its own breach is
2468        // reported, `landed_breaches`), never trusted by a breached walk.
2469        let mut walked: HashSet<String> = if walks && landing.subject_walk_ran {
2470            walk_cover(&beef, subject)
2471        } else {
2472            HashSet::new()
2473        };
2474        let mut stack = vec![predecessor.to_string()];
2475        let mut guarded: HashSet<String> = HashSet::new();
2476        while let Some(next) = stack.last().cloned() {
2477            let body = beef
2478                .find_atomic_transaction(&next)
2479                .ok_or_else(|| format!("{next}: its body is not in the BEEF"))?;
2480            // The caller's admission predicate, before anything of this body
2481            // is submitted (the delta-2 fold, L2).
2482            if let Some(guard) = &self.landing_guard {
2483                if guarded.insert(next.clone()) {
2484                    bounded(bound, guard(&next, topic))
2485                        .await
2486                        .and_then(|answer| answer)
2487                        .map_err(|why| format!("{next}: refused by the landing guard ({why})"))?;
2488                }
2489            }
2490            // Its validation (the applied row, one coin per input) and its
2491            // writes' one read of each coin it spends.
2492            let cost = 1 + 2 * body.inputs.len();
2493            if *question_reads + cost > PREDECESSOR_READS_PER_SUBMIT {
2494                return Err(format!(
2495                    "{next}: its submit does not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2496                ));
2497            }
2498            *question_reads += cost;
2499            let atomic = body
2500                .to_atomic_beef(true)
2501                .map_err(|e| format!("{next}: its BEEF could not be built ({e})"))?;
2502            let tagged = TaggedBEEF::new(atomic, vec![topic.to_string()]);
2503            let (_, report, blocked) = match self
2504                .submit_counted(
2505                    &tagged,
2506                    mode,
2507                    bound,
2508                    false,
2509                    Some(&walked),
2510                    question_reads,
2511                    landing.policy,
2512                )
2513                .await
2514            {
2515                Ok(counted) => counted,
2516                // Its walk could not run and nothing stands in for it: it does
2517                // not land now, and the successor waits (counted, L3).
2518                Err(EngineError::WalkCouldNotRun(stop)) => {
2519                    let why = format!("{next}: {}", EngineError::WalkCouldNotRun(stop.clone()));
2520                    landed_breaches.push(stop);
2521                    return Err(why);
2522                }
2523                Err(e) => return Err(format!("{next}: {e}")),
2524            };
2525            // Its walk passed, so did that of every body it reaches; a walk
2526            // that went on past a breach (`NetworkAccepted`) passed nothing.
2527            if let Some(stop) = &report.walk_could_not_run {
2528                landed_breaches.push(stop.clone());
2529            } else if walks && !walked.contains(&next) {
2530                walked.extend(walk_cover(&beef, &next));
2531            }
2532            let landed_now = report.applied_topics.iter().any(|t| t == topic);
2533            if report.is_durable()
2534                && (landed_now || report.deduped_topics.iter().any(|t| t == topic))
2535            {
2536                info!("topic {topic}: {next} landed first from its successor's BEEF");
2537                if landed_now {
2538                    landed.push((next.clone(), topic.to_string()));
2539                }
2540                stack.pop();
2541                continue;
2542            }
2543            match blocked.get(topic) {
2544                Some(under) if !stack.contains(under) => stack.push(under.clone()),
2545                _ if report.is_durable() => return Err(format!("{next}: the manager failed it")),
2546                _ => return Err(format!("{next}: {}", report.summary())),
2547            }
2548        }
2549        Ok(())
2550    }
2551
2552    /// A topic of a submit that is NOT recorded as applied: logged, and
2553    /// remembered for the successor rule (`not_landed`).
2554    fn hold_unapplied(&self, txid: &str, topic: &str, why: &str) {
2555        warn!("topic {topic}: {txid}: {why}; applied_transactions NOT recorded (bsv-low #559)");
2556        self.not_landed
2557            .borrow_mut()
2558            .insert((txid.to_string(), topic.to_string()));
2559    }
2560
2561    /// Take out again the outputs a faulted submit inserted (bsv-low #559,
2562    /// the lens fold of 2026-10-07, F1), so that nothing of a transaction
2563    /// that did not land is held. Only a row nobody has touched since is
2564    /// deleted: unspent and consumed by nothing. A plain delete, not the
2565    /// deep one: the row's retained inputs were not pointed at it yet. A
2566    /// fault here is reported (`undo_insert_output`) and the row stays: the
2567    /// limit of an undo made of separate calls.
2568    async fn undo_inserts(
2569        &self,
2570        txid: &str,
2571        topic: &str,
2572        output_indices: &[u32],
2573        bound: Option<&CallBound>,
2574        report: &mut MutationReport,
2575    ) {
2576        for &output_index in output_indices {
2577            let row = stored(
2578                bound,
2579                self.storage
2580                    .find_output(txid, output_index, Some(topic), None, false),
2581            )
2582            .await;
2583            let undone = match row {
2584                Ok(Some(row)) if !row.spent && row.consumed_by.is_empty() => {
2585                    stored(bound, self.storage.delete_output(txid, output_index, topic)).await
2586                }
2587                Ok(_) => Ok(()),
2588                Err(e) => Err(e),
2589            };
2590            if let Err(e) = undone {
2591                error!("Error taking {txid}.{output_index} out of topic {topic} again: {e}");
2592                report.fault(topic, "undo_insert_output", e.to_string());
2593            }
2594        }
2595    }
2596
2597    /// The store's own answer to "has a predecessor of `tx` not landed?"
2598    /// (bsv-low #559, the lens fold of 2026-10-07, F3; lane E1D, bsv-low
2599    /// #575, and its lens fold of 2026-10-08), for a transaction that found
2600    /// no previous coin. The engine's memory of a faulted submit
2601    /// (`not_landed`) dies with the invocation; this does not. A transaction
2602    /// HAS LANDED in `topic` when it has an applied row there or an output
2603    /// held there; anything else is not known to have landed, and it is a
2604    /// PREDECESSOR of `tx` when one of these says so:
2605    ///
2606    /// - its body is in the BEEF and it spends a coin the topic HOLDS (or,
2607    ///   one step up, a coin of another such transaction): a spend of this
2608    ///   topic the store has not taken yet (#559);
2609    /// - its body is in the BEEF, it spends no coin the topic holds, the
2610    ///   manager NAMES the outpoint the walk spends from it as overlay
2611    ///   history (`identify_needed_inputs` over the BEEF of the transaction
2612    ///   that spends it, `tx` or a body the walk reached), and the manager,
2613    ///   asked of that body in a dry run with no coins
2614    ///   (`TopicAdmittanceContext::DRY_RUN`, the submit's mode, no off-chain
2615    ///   values), would admit that named output: an OPENER that has not
2616    ///   landed (E1D, the cure D17 named). Only a NAMED output is dry-run
2617    ///   (the lens fold, H1): with every unheld body dry-run, a manager that
2618    ///   admits on output shape (all 16 of this workspace) made a spend of a
2619    ///   shape-admissible output this node never held (a revocation of an
2620    ///   ad it never saw; a stranger's own few-sat SHIP output) "not now" on
2621    ///   every presentation, three replays and a dead letter each, where the
2622    ///   reference records it. A manager `Err` in the dry run is "not now"
2623    ///   (the manager's contract at the anchor replay, D15), except its
2624    ///   typed refusal `NoAdmissibleOutputs`, which is "not admitted";
2625    /// - its body is NOT in the BEEF (a PROVEN successor carries none) and
2626    ///   the manager names the outpoint `tx` spends from it as overlay
2627    ///   history (D13's word; an `Err` there names nothing, as in the walk).
2628    ///
2629    /// The manager is asked over `beef_bytes`, the SUBJECT-NAMED BEEF of the
2630    /// submit (BRC-95 atomic, the body the lookup services get; the lens
2631    /// fold, M2): over the submitted bytes a manager that parses
2632    /// `from_beef(_, None)` took the wire-LAST transaction of a non-atomic
2633    /// BEEF, named an ancestor's inputs, and the question asked nothing.
2634    ///
2635    /// `admits` says which inputs of `tx` start the walk. A transaction that
2636    /// admits nothing (D17's class) starts from every input. One that admits
2637    /// WITHOUT its coin (E1D) starts only from the inputs the manager names:
2638    /// a manager that admits a successor with no coin has said the coin does
2639    /// not decide the admission, so only its own word makes an input a
2640    /// predecessor. Every manager of this workspace names nothing: an
2641    /// admitting successor is then asked nothing, as in the reference, and
2642    /// so is a spend of an unheld output nobody names (the lens fold, H1);
2643    /// but a successor that admits nothing still finds case (a), a carried
2644    /// body that spends a coin the topic holds, and waits for it or has it
2645    /// landed first, where the reference records the successor and never
2646    /// admits that body (the E1D delta fold, L3). A named input whose body
2647    /// the BEEF carries is still walked and dry-run, so a named decoy the
2648    /// manager would not admit is no predecessor.
2649    ///
2650    /// The answer carries the predecessor when its body is in the BEEF (the
2651    /// first two cases): the door lands it first ([`Engine::successors_waiting`]).
2652    ///
2653    /// The limits, stated. It cannot tell a faulted predecessor from one
2654    /// nobody submitted yet: a successor is "not now" in both until the
2655    /// predecessor lands (the door lands a carried one itself). A named
2656    /// input whose body is absent and which never lands (a decoy no one
2657    /// submits, beside a real predecessor that landed and holds no coin)
2658    /// keeps its successor "not now" at the door; the GASP walk prunes such
2659    /// an input instead (D14). Deeper than the successor's own inputs, an
2660    /// absent body ends the walk as before.
2661    ///
2662    /// "Landed" needs a clean answer (the delta fold of 2026-10-07, M1 and
2663    /// M2): a read that faults, or the question running out of its reads
2664    /// (the 16 of a topic or the 256 of the submit), answers "not now" too,
2665    /// naming the transaction it could not settle. Recording the successor
2666    /// there made its replay a dupe and stopped the chain behind it for
2667    /// good; a retried no-op is the lesser cost.
2668    ///
2669    /// What the bound counts (the second delta fold of 2026-10-07, M2): the
2670    /// reads spent on a body that is NEITHER proven in the BEEF NOR found
2671    /// landed, [`PREDECESSOR_READS`] of them per topic. A body the BEEF
2672    /// proves, and one whose applied row or held output answers "landed", is
2673    /// read and costs nothing against that bound: counted, they made a
2674    /// transaction that admits nothing over 17 landed parents, over six
2675    /// proven parents the topic never saw, or over one proven parent with 15
2676    /// inputs "not now" on every submit, with no unlanded predecessor
2677    /// anywhere. A landed body still needs ROOM under it: each read of an
2678    /// unproven body is tested before it is made, so with 15 counted reads
2679    /// spent one that landed by a held output (no applied row) is refused at
2680    /// its second read, and with 16 spent any unproven body at its first.
2681    ///
2682    /// "Proven" is the BEEF's word here: a body that carries a merkle path
2683    /// (a bump index) is counted as proven for the read bound ONLY. No proof
2684    /// is checked at this question; the SPV walk of the submit and the GASP
2685    /// anchor check are where proofs are checked, and under
2686    /// `historical-tx-no-spv` or the scripts-only walk nothing checked this
2687    /// one, so the 16 are the caller's to switch off. Nothing is recorded on
2688    /// that word (a proven body is walked like any other), and the allowance
2689    /// below does not ask it.
2690    ///
2691    /// The hard allowance (the third delta fold of 2026-10-07, M1):
2692    /// `question_reads`, owned by the submit, counts EVERY read of the
2693    /// question, those of proven and landed bodies included, over every
2694    /// topic of the submit, and stops it at
2695    /// [`PREDECESSOR_READS_PER_SUBMIT`]; the 16 stay inside it. A later
2696    /// topic's question starts from what the earlier ones left.
2697    ///
2698    /// The costs, stated. A transaction that admits nothing, found no coin
2699    /// and carries more UNPROVEN, UNLANDED bodies than 16 reads settle (five
2700    /// single-input ancestors, fewer with more inputs), or a BEEF whose
2701    /// question needs more than 256 reads over the submit's topics (a
2702    /// landed body costs one read by its applied row or two by a held
2703    /// output, a proven, unlanded one two and one per input), is never
2704    /// recorded, where the reference records it: each submit of it is a
2705    /// fault and a replay. The manager CPU it adds: one names call per
2706    /// spender a dry run needs (the subject's, and one per walked body with
2707    /// an unheld source), and at most one dry run per body the reads
2708    /// reached; each candidate is built out of the BEEF parsed once (the
2709    /// lens fold, L2).
2710    ///
2711    /// Returns why the submit must wait, for the report.
2712    #[allow(clippy::too_many_arguments)]
2713    async fn unlanded_predecessor(
2714        &self,
2715        tx: &Transaction,
2716        txid: &str,
2717        beef_bytes: &[u8],
2718        topic: &str,
2719        mode: SubmitMode,
2720        admits: bool,
2721        subject_names: &mut Option<HashSet<String>>,
2722        question_reads: &mut usize,
2723        bound: Option<&CallBound>,
2724    ) -> Option<NotNow> {
2725        let manager = self.managers.get(topic)?;
2726        let wait = |why: String| Some(NotNow { why, carried: None });
2727        // The outpoints `tx` spends, as `txid.vout`.
2728        let starts: Vec<(String, u32)> = tx
2729            .inputs
2730            .iter()
2731            .filter_map(|input| {
2732                let source = input.get_source_txid().ok()?;
2733                (!source.is_empty()).then_some((source, input.source_output_index))
2734            })
2735            .collect();
2736        // The outputs of each candidate that the walk spends, and who spends
2737        // each: a dry run asks only of an output its spender names.
2738        let mut spent_of: HashMap<String, HashSet<u32>> = HashMap::new();
2739        let mut spenders: HashMap<String, Vec<(String, u32)>> = HashMap::new();
2740        let mut ask: Vec<String> = Vec::new();
2741        for (source, vout) in &starts {
2742            if admits
2743                && subject_names
2744                    .as_ref()
2745                    .is_some_and(|named| !named.contains(&format!("{source}.{vout}")))
2746            {
2747                continue;
2748            }
2749            spent_of.entry(source.clone()).or_default().insert(*vout);
2750            spenders
2751                .entry(source.clone())
2752                .or_default()
2753                .push((txid.to_string(), *vout));
2754            ask.push(source.clone());
2755        }
2756        // Nothing to ask (a transaction that admits and names nothing, every
2757        // manager of this workspace): the BEEF is not even parsed.
2758        if ask.is_empty() {
2759            return None;
2760        }
2761        let direct: HashSet<String> = ask.iter().cloned().collect();
2762        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS).ok()?;
2763        // Each body the BEEF carries, and whether the BEEF says it is
2764        // proven (a bump index: its word, no proof is checked here).
2765        let bodies: HashMap<String, (&Transaction, bool)> = beef
2766            .txs
2767            .iter()
2768            .filter_map(|btx| {
2769                btx.tx()
2770                    .map(|body| (btx.txid(), (body, btx.bump_index().is_some())))
2771            })
2772            .collect();
2773        // The manager's names over each walked body other than `tx`.
2774        let mut names_over: HashMap<String, HashSet<String>> = HashMap::new();
2775        // The reads spent on bodies neither proven nor found landed.
2776        let mut reads = 0usize;
2777        let mut asked: HashSet<String> = HashSet::new();
2778        while let Some(candidate) = ask.pop() {
2779            if candidate.is_empty() || !asked.insert(candidate.clone()) {
2780                continue;
2781            }
2782            let spent_outputs = spent_of.get(&candidate).cloned().unwrap_or_default();
2783            // An absent body is asked of the store only when it is one of
2784            // `tx`'s own inputs and the manager names it (E1D).
2785            let body = bodies.get(&candidate).copied();
2786            if body.is_none() {
2787                if !direct.contains(&candidate) {
2788                    continue;
2789                }
2790                if subject_names.is_none() {
2791                    *subject_names = Some(
2792                        self.named_history(manager.as_ref(), beef_bytes, bound)
2793                            .await,
2794                    );
2795                }
2796                let is_named = subject_names.as_ref().is_some_and(|named| {
2797                    spent_outputs
2798                        .iter()
2799                        .any(|vout| named.contains(&format!("{candidate}.{vout}")))
2800                });
2801                if !is_named {
2802                    continue;
2803                }
2804            }
2805            let proven = body.is_some_and(|(_, proven)| proven);
2806            // This candidate's own reads: they join `reads` once it is
2807            // found not landed, and never if the BEEF proves it.
2808            let mut spent = 0usize;
2809            let out_of = |spent: usize| !proven && reads + spent >= PREDECESSOR_READS;
2810            let out_of_reads = || {
2811                wait(format!(
2812                    "{candidate} is not known to have landed: the question ran out of its {PREDECESSOR_READS} reads"
2813                ))
2814            };
2815            // The submit's allowance, over every read: proven or landed,
2816            // this topic or an earlier one.
2817            let out_of_allowance = || {
2818                wait(format!(
2819                    "{candidate} is not known to have landed: the question ran out of the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2820                ))
2821            };
2822            let unread = |e: StorageError| {
2823                wait(format!(
2824                    "{candidate} is not known to have landed: the store could not say ({e})"
2825                ))
2826            };
2827            let carried = |why: String| {
2828                Some(NotNow {
2829                    why,
2830                    carried: Some(candidate.clone()),
2831                })
2832            };
2833            let record = AppliedTransaction {
2834                txid: candidate.clone(),
2835                topic: topic.to_string(),
2836            };
2837            // An applied row, or an output held, ends the question for this
2838            // transaction: it landed.
2839            if out_of(spent) {
2840                return out_of_reads();
2841            }
2842            if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2843                return out_of_allowance();
2844            }
2845            spent += 1;
2846            *question_reads += 1;
2847            match stored(bound, self.storage.does_applied_transaction_exist(&record)).await {
2848                Ok(false) => {}
2849                Ok(true) => continue,
2850                Err(e) => return unread(e),
2851            }
2852            if out_of(spent) {
2853                return out_of_reads();
2854            }
2855            if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2856                return out_of_allowance();
2857            }
2858            spent += 1;
2859            *question_reads += 1;
2860            match stored(
2861                bound,
2862                self.storage.find_outputs_for_transaction(&candidate, false),
2863            )
2864            .await
2865            {
2866                Ok(outputs) if outputs.iter().all(|o| o.topic != topic) => {}
2867                Ok(_) => continue,
2868                Err(e) => return unread(e),
2869            }
2870            // Not landed. With no body to walk, the manager's word is the
2871            // whole answer (E1D).
2872            let Some((body, _)) = body else {
2873                return wait(format!(
2874                    "{candidate} has not landed (the manager names it as history; the BEEF does not carry it)"
2875                ));
2876            };
2877            for input in &body.inputs {
2878                let source = input.get_source_txid().unwrap_or_default();
2879                if source.is_empty() {
2880                    continue;
2881                }
2882                if out_of(spent) {
2883                    return out_of_reads();
2884                }
2885                if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2886                    return out_of_allowance();
2887                }
2888                spent += 1;
2889                *question_reads += 1;
2890                let coin = stored(
2891                    bound,
2892                    self.storage.find_output(
2893                        &source,
2894                        input.source_output_index,
2895                        Some(topic),
2896                        None,
2897                        false,
2898                    ),
2899                )
2900                .await;
2901                match coin {
2902                    Ok(Some(_)) => return carried(format!("{candidate} has not landed")),
2903                    // Not held: its own transaction may be the one that has
2904                    // not landed.
2905                    Ok(None) => {
2906                        spent_of
2907                            .entry(source.clone())
2908                            .or_default()
2909                            .insert(input.source_output_index);
2910                        spenders
2911                            .entry(source.clone())
2912                            .or_default()
2913                            .push((candidate.clone(), input.source_output_index));
2914                        ask.push(source);
2915                    }
2916                    Err(e) => return unread(e),
2917                }
2918            }
2919            // It spends no coin the topic holds. Does a spender NAME an
2920            // output the walk spends of it (the lens fold, H1)? A names call
2921            // per spender, no store read.
2922            let mut named_outputs: HashSet<u32> = HashSet::new();
2923            for (spender, vout) in spenders.get(&candidate).cloned().unwrap_or_default() {
2924                let names = if spender == txid {
2925                    if subject_names.is_none() {
2926                        *subject_names = Some(
2927                            self.named_history(manager.as_ref(), beef_bytes, bound)
2928                                .await,
2929                        );
2930                    }
2931                    subject_names.as_ref()
2932                } else {
2933                    if !names_over.contains_key(&spender) {
2934                        let names = match beef
2935                            .find_atomic_transaction(&spender)
2936                            .and_then(|spender| spender.to_atomic_beef(true).ok())
2937                        {
2938                            Some(spender_beef) => {
2939                                self.named_history(manager.as_ref(), &spender_beef, bound)
2940                                    .await
2941                            }
2942                            None => HashSet::new(),
2943                        };
2944                        names_over.insert(spender.clone(), names);
2945                    }
2946                    names_over.get(&spender)
2947                };
2948                if names.is_some_and(|named| named.contains(&format!("{candidate}.{vout}"))) {
2949                    named_outputs.insert(vout);
2950                }
2951            }
2952            if !named_outputs.is_empty() {
2953                // Would the manager admit a named output of it with no coins
2954                // (E1D, the opener)? A dry run over the candidate built out
2955                // of the BEEF parsed above (its proof and, unproven, its
2956                // sources linked): no store read, nothing counted.
2957                let judged = beef
2958                    .find_atomic_transaction(&candidate)
2959                    .unwrap_or_else(|| body.clone());
2960                let verdict = bounded(
2961                    bound,
2962                    manager.identify_admissible_outputs(
2963                        &judged,
2964                        &[],
2965                        None,
2966                        mode,
2967                        &TopicAdmittanceContext::DRY_RUN,
2968                    ),
2969                )
2970                .await;
2971                match verdict {
2972                    Ok(Ok(admittance)) => {
2973                        if admittance
2974                            .outputs_to_admit
2975                            .iter()
2976                            .any(|vout| named_outputs.contains(vout))
2977                        {
2978                            return carried(format!(
2979                                "{candidate} has not landed (the manager names the output spent from it and would admit it with no coin: an opener)"
2980                            ));
2981                        }
2982                    }
2983                    Ok(Err(TopicManagerError::NoAdmissibleOutputs(_))) => {}
2984                    Ok(Err(e)) => {
2985                        return wait(format!(
2986                            "{candidate} is not known to have landed: the manager could not judge it ({e})"
2987                        ))
2988                    }
2989                    Err(no_answer) => {
2990                        return wait(format!(
2991                            "{candidate} is not known to have landed: the manager did not answer ({no_answer})"
2992                        ))
2993                    }
2994                }
2995            }
2996            if !proven {
2997                reads += spent;
2998            }
2999        }
3000        None
3001    }
3002
3003    /// The outpoints (`txid.vout`) the topic manager NAMES as overlay
3004    /// history of the transaction `beef_bytes` names (`identify_needed_inputs`,
3005    /// D13's word; an atomic BEEF, so a manager that parses
3006    /// `from_beef(_, None)` reads that transaction). An `Err` or no answer
3007    /// names nothing, as the GASP walk treats it (logged).
3008    async fn named_history(
3009        &self,
3010        manager: &dyn TopicManager,
3011        beef_bytes: &[u8],
3012        bound: Option<&CallBound>,
3013    ) -> HashSet<String> {
3014        match bounded(bound, manager.identify_needed_inputs(beef_bytes, None)).await {
3015            Ok(Ok(named)) => named.iter().map(Outpoint::to_graph_id).collect(),
3016            Ok(Err(e)) => {
3017                warn!("the predecessor question: the manager could not name its inputs: {e}");
3018                HashSet::new()
3019            }
3020            Err(no_answer) => {
3021                warn!("the predecessor question: the manager did not name its inputs: {no_answer}");
3022                HashSet::new()
3023            }
3024        }
3025    }
3026
3027    /// The reference's `tx.verify(chainTracker)` on submit: every unproven
3028    /// transaction from the subject down has every input's script executed
3029    /// against its source output and obeys the value rule; a proven one is
3030    /// not descended, and its BUMP's root is checked against the chain
3031    /// tracker (computed and accepted with none).
3032    ///
3033    /// Since bsv-low #592 (the owner's ruling 3a of 2026-10-10) the scripts
3034    /// are walked by the DOOR's stream walk ([`crate::script_door`]) under
3035    /// `budget`, the engine's [`DoorBudget`]: one walker, the same estimates
3036    /// charged from the bytes before anything is parsed or run. Before it this
3037    /// was a walk of its own over a hydrated `Beef` with no memory charge and
3038    /// no interpreter limit: 219.7 MB natively on a 1.9 MB push-only unproven
3039    /// parent, an isolate kill on Workers, met by every `historical-tx` replay
3040    /// of the queue. The roots are then asked for the proven transactions the
3041    /// walk reached, in its order, once per BUMP.
3042    ///
3043    /// `Ok(Some(_))` is a walk that COULD NOT RUN within the budget, never a
3044    /// refusal (below, at the match). The interpreter's refusal and a
3045    /// structural fault are what they were: `ScriptVerificationFailed`, and
3046    /// `SpvError` in the same words.
3047    ///
3048    /// The tracker is an ARGUMENT, not `self`'s: the GASP anchor check
3049    /// ([`verify_spv_like_the_reference`]) runs this same walk from
3050    /// `OverlayGASPStorage`, which borrows the engine's tracker.
3051    ///
3052    /// Why not bsv-rs `Transaction::verify`: it walks `source_transaction`
3053    /// links that `from_beef` builds by CLONING, once per input. Two inputs
3054    /// sourcing the same unproven parent (a covenant head output plus that
3055    /// spend's own change, the shape of every second head spend of a wallet)
3056    /// leave the second clone bare ("Input 0 has no source transaction"), and
3057    /// re-linking every clone materializes the ancestry EXPONENTIALLY along a
3058    /// diamond chain: a 12-deep unmined chain of head spends took a Worker past
3059    /// its memory on beta (zanaadu, 2026-09-08). The stream walk reads each
3060    /// transaction once, in place, keyed by txid.
3061    async fn verify_beef_linear(
3062        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3063        beef_bytes: &[u8],
3064        subject_txid: &str,
3065        trusted: &HashSet<String>,
3066        budget: DoorBudget,
3067    ) -> Result<Option<WalkCouldNotRun>, EngineError> {
3068        // A structural fault is the reference walk's `SpvError`.
3069        let spv =
3070            |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3071
3072        // THE SCRIPTS (bsv-low #592): the door's own stream walk, under the
3073        // engine's budget. One walker (Rule 10): the door and the submit run
3074        // the same function, the same estimates and the same interpreter
3075        // limit; the submit adds the roots below.
3076        let mut trace = crate::script_door::WalkTrace::default();
3077        match crate::script_door::walk_trusting(
3078            beef_bytes,
3079            subject_txid,
3080            budget,
3081            trusted,
3082            &mut trace,
3083        ) {
3084            Ok(_) => {}
3085            // The interpreter's verdict, the reference's refusal: unchanged.
3086            Err(refused @ EngineError::ScriptVerificationFailed { .. }) => return Err(refused),
3087            // A structural fault (a source missing, the value rule, ...):
3088            // the reference walk's `SpvError`, in the same words as before.
3089            Err(EngineError::ScriptWalkInconclusive { reason, .. }) => return Err(spv(reason)),
3090            // The budget, not the transaction: the walk COULD NOT RUN. Not a
3091            // refusal: the bytes are a BEEF the walk was not given the room
3092            // to judge, which says nothing about whether its scripts are
3093            // valid (refusing would refuse a valid BEEF for its size, the
3094            // owner's posture). Not an admission either: the caller's
3095            // [`WalkBreachPolicy`] decides, and only the network's word on
3096            // record lets the submit go on (the E592 lens fold, H1). The roots
3097            // the walk reached are checked first, below.
3098            Err(EngineError::ScriptWalkOverBudget {
3099                at_txid,
3100                subject_judged,
3101                limb,
3102                what,
3103            }) => {
3104                let limb = match limb {
3105                    DoorLimb::Memory => WalkLimb::OverMemory,
3106                    DoorLimb::Work if trace.interpreter_tripped => WalkLimb::InterpreterMemory,
3107                    DoorLimb::Work => WalkLimb::OverWork,
3108                };
3109                // THE ROOTS OF A BREACHED WALK (the E592 lens fold, H1): every
3110                // proven transaction it reached, and every proven source an
3111                // input read, whatever the scripts' verdict. A bad root is
3112                // the `SpvError` it is on a walk that ran: a breach never
3113                // skips a proof the walk relied on. Their cost is a tracker
3114                // question per BUMP, which the budget does not bound and
3115                // does not need to (the clean walk asks the same).
3116                let reached: Vec<String> = trace
3117                    .proven
3118                    .iter()
3119                    .chain(&trace.proven_sources)
3120                    .cloned()
3121                    .collect();
3122                Self::check_reached_roots(chain_tracker, beef_bytes, &reached).await?;
3123                return Ok(Some(WalkCouldNotRun {
3124                    subject_txid: subject_txid.to_string(),
3125                    at_txid,
3126                    subject_judged,
3127                    limb,
3128                    what,
3129                }));
3130            }
3131            Err(other) => return Err(other),
3132        }
3133
3134        // THE ROOTS: every proven transaction the walk reached, in the order
3135        // it reached them, its BUMP's root computed and asked of the tracker
3136        // once per BUMP (NL-6e). With no tracker the roots are computed and
3137        // accepted (`'scripts only'`). The walk trusted them as it went; the
3138        // reference checks them in the same walk, so a body with BOTH a bad
3139        // script and a bad root now names the script.
3140        Self::check_reached_roots(chain_tracker, beef_bytes, &trace.proven).await?;
3141        Ok(None)
3142    }
3143
3144    /// The roots of `reached` (display txids, in order; a repeat or a second
3145    /// transaction of one BUMP asks nothing more), each BUMP's root computed
3146    /// and asked of the tracker once. None reached: the body is not parsed.
3147    async fn check_reached_roots(
3148        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3149        beef_bytes: &[u8],
3150        reached: &[String],
3151    ) -> Result<(), EngineError> {
3152        let spv =
3153            |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3154        if reached.is_empty() {
3155            return Ok(());
3156        }
3157        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
3158            .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3159        let proven_in = proven_index(&beef);
3160        let mut checked: HashSet<usize> = HashSet::new();
3161        for txid in reached {
3162            let Some(&at) = proven_in.get(txid.as_str()) else {
3163                return Err(spv(format!("transaction {txid} is not in the BEEF")));
3164            };
3165            if !checked.insert(at) {
3166                continue; // its BUMP's root is the chain's, asked once
3167            }
3168            let mp = &beef.bumps[at];
3169            let root = mp
3170                .compute_root(Some(txid))
3171                .map_err(|e| spv(format!("invalid merkle path for transaction {txid}: {e}")))?;
3172            if let Some(tracker) = chain_tracker {
3173                match tracker
3174                    .is_valid_root_for_height(&root, mp.block_height)
3175                    .await
3176                {
3177                    Ok(true) => {}
3178                    Ok(false) => {
3179                        return Err(EngineError::SpvError(format!(
3180                            "Invalid merkle path for transaction {txid}: root {root} is not valid for block height {}",
3181                            mp.block_height
3182                        )));
3183                    }
3184                    Err(e) => {
3185                        return Err(EngineError::SpvError(format!(
3186                            "Chain tracker error at height {}: {e}",
3187                            mp.block_height
3188                        )));
3189                    }
3190                }
3191            }
3192        }
3193        Ok(())
3194    }
3195
3196    /// The pre-2026-09-08 SPV block, kept as the
3197    /// [`Engine::set_script_verification`] `false` escape hatch: BEEF
3198    /// structural validity plus every root against the chain tracker, and
3199    /// NOTHING when no tracker is configured. It never executes a script.
3200    ///
3201    /// The structure is the streaming reader's
3202    /// ([`beef_limits::structure_roots`], bsv-rs 0.4.2): one element in hand,
3203    /// each BUMP walked once, linear in its leaves (NL-6e). Until NL-6e it
3204    /// was `Beef::verify_valid`, whose root check at bsv-rs 0.4.0 walked a
3205    /// BUMP once per leaf: one BUMP of 8,192 leaves gave no answer in 120 s
3206    /// (bsv-stack-lean NL-8 W1). The reader reads the wire's order, where
3207    /// `verify_valid` sorted first: an input that names a later transaction
3208    /// is refused here.
3209    async fn verify_spv_structurally(
3210        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3211        beef_bytes: &[u8],
3212    ) -> Result<(), EngineError> {
3213        let Some(chain_tracker) = chain_tracker else {
3214            return Ok(());
3215        };
3216        let roots = beef_limits::structure_roots(beef_bytes)
3217            .map_err(|r| EngineError::SpvError(format!("BEEF structure refused: {r}")))?;
3218        let mut asked: HashSet<(u64, &str)> = HashSet::new();
3219        for (height, root) in &roots {
3220            if !asked.insert((*height, root.as_str())) {
3221                continue;
3222            }
3223            let Ok(height) = u32::try_from(*height) else {
3224                return Err(EngineError::SpvError(format!(
3225                    "Merkle root {root} invalid for block height {height}"
3226                )));
3227            };
3228            match chain_tracker.is_valid_root_for_height(root, height).await {
3229                Ok(true) => {}
3230                Ok(false) => {
3231                    return Err(EngineError::SpvError(format!(
3232                        "Merkle root {root} invalid for block height {height}"
3233                    )));
3234                }
3235                Err(e) => {
3236                    return Err(EngineError::SpvError(format!(
3237                        "Chain tracker error at height {height}: {e}"
3238                    )));
3239                }
3240            }
3241        }
3242        Ok(())
3243    }
3244
3245    /// Phase 1 of ONE topic: the dedup read, the previous coins, the
3246    /// manager's judgement. Read-only. [`Engine::run_validation`] asks it of
3247    /// every topic; the door asks it again of a topic whose predecessor it
3248    /// landed first from the BEEF (lane E1D's lens fold, M1).
3249    #[allow(clippy::too_many_arguments)]
3250    async fn validate_topic(
3251        &self,
3252        tx: &Transaction,
3253        txid: &str,
3254        topic: &str,
3255        off_chain_values: Option<&[u8]>,
3256        mode: SubmitMode,
3257        context: &TopicAdmittanceContext,
3258        bound: Option<&CallBound>,
3259    ) -> TopicValidation {
3260        let tx_record = AppliedTransaction {
3261            txid: txid.to_string(),
3262            topic: topic.to_string(),
3263        };
3264        // A dedup read that FAULTS is the topic's read fault (the delta
3265        // fold of 2026-10-07, H3): nothing is judged and nothing is
3266        // written for the topic, the fault rides the report and the
3267        // replay asks again. The reference fails the topic there
3268        // (`Engine.ts` `submit`: the throw lands in the per-topic catch,
3269        // `failedTopics.add`). Read as "not a dupe", as it was, a
3270        // re-presented transaction the manager admits with no previous
3271        // coin was inserted a second time, unspent, beside the output
3272        // that had spent it, under a durable report.
3273        let is_dupe = match stored(
3274            bound,
3275            self.storage.does_applied_transaction_exist(&tx_record),
3276        )
3277        .await
3278        {
3279            Ok(is_dupe) => is_dupe,
3280            Err(e) => {
3281                error!("Error reading the applied row of {txid} for topic {topic}: {e}");
3282                return TopicValidation {
3283                    topic: topic.to_string(),
3284                    is_dupe: false,
3285                    previous_coins: vec![],
3286                    previous_outputs: vec![],
3287                    admittance: AdmittanceInstructions::default(),
3288                    failed: false,
3289                    read_fault: Some(("does_applied_transaction_exist", e.to_string())),
3290                };
3291            }
3292        };
3293
3294        if is_dupe {
3295            return TopicValidation {
3296                topic: topic.to_string(),
3297                is_dupe: true,
3298                previous_coins: vec![],
3299                previous_outputs: vec![],
3300                admittance: AdmittanceInstructions::default(),
3301                failed: false,
3302                read_fault: None,
3303            };
3304        }
3305
3306        let mut previous_coins: Vec<u32> = Vec::new();
3307        let mut previous_outputs: Vec<Output> = Vec::new();
3308        let mut read_fault: Option<(&'static str, String)> = None;
3309
3310        for (input_idx, input) in tx.inputs.iter().enumerate() {
3311            let source_txid = input.get_source_txid().unwrap_or_default();
3312            if source_txid.is_empty() {
3313                continue;
3314            }
3315            match stored(
3316                bound,
3317                self.storage.find_output(
3318                    &source_txid,
3319                    input.source_output_index,
3320                    Some(topic),
3321                    None,
3322                    false,
3323                ),
3324            )
3325            .await
3326            {
3327                Ok(Some(prev_output)) => {
3328                    previous_coins.push(input_idx as u32);
3329                    previous_outputs.push(prev_output);
3330                }
3331                Ok(None) => {}
3332                Err(e) => {
3333                    // A faulted read is NOT "no previous coin": the
3334                    // spend may consume an admitted output we simply
3335                    // could not read. Validation proceeds (the topic
3336                    // manager decides on what it can see) but the fault
3337                    // rides the report and blocks the applied record.
3338                    error!(
3339                        "Error reading previous output {source_txid}:{} for topic {topic}: {e}",
3340                        input.source_output_index
3341                    );
3342                    if read_fault.is_none() {
3343                        read_fault = Some(("find_output", e.to_string()));
3344                    }
3345                }
3346            }
3347        }
3348
3349        let judged = self
3350            .judge(
3351                tx,
3352                topic,
3353                &previous_coins,
3354                off_chain_values,
3355                mode,
3356                context,
3357                bound,
3358            )
3359            .await;
3360        match judged {
3361            Ok(admittance) => TopicValidation {
3362                topic: topic.to_string(),
3363                is_dupe: false,
3364                previous_coins,
3365                previous_outputs,
3366                admittance,
3367                failed: false,
3368                read_fault: read_fault.clone(),
3369            },
3370            Err(e) => {
3371                error!("Error validating topic {topic} during submit: {e}");
3372                TopicValidation {
3373                    topic: topic.to_string(),
3374                    is_dupe: false,
3375                    previous_coins: vec![],
3376                    previous_outputs: vec![],
3377                    admittance: AdmittanceInstructions::default(),
3378                    failed: true,
3379                    read_fault,
3380                }
3381            }
3382        }
3383    }
3384
3385    /// The topic manager's judgement of `tx` over the previous coins found
3386    /// (input indices), as [`Engine::validate_topic`] asks it; a carried
3387    /// predecessor's submit asks it again for real once its topic does not
3388    /// wait (the E1D delta fold, L4).
3389    #[allow(clippy::too_many_arguments)]
3390    async fn judge(
3391        &self,
3392        tx: &Transaction,
3393        topic: &str,
3394        previous_coins: &[u32],
3395        off_chain_values: Option<&[u8]>,
3396        mode: SubmitMode,
3397        context: &TopicAdmittanceContext,
3398        bound: Option<&CallBound>,
3399    ) -> Result<AdmittanceInstructions, TopicManagerError> {
3400        let manager = &self.managers[topic];
3401        let previous_coin_bytes = previous_coins
3402            .iter()
3403            .flat_map(|i| i.to_le_bytes())
3404            .collect::<Vec<u8>>();
3405        // Under a finalize submit's bound a manager that does not answer
3406        // is a manager that failed: nothing is written for the topic.
3407        bounded(
3408            bound,
3409            manager.identify_admissible_outputs(
3410                // The ONE engine parse of the submitted BEEF, shared by
3411                // every topic on this submit (bsv-low #289 — managers
3412                // used to re-parse the same bytes independently).
3413                tx,
3414                &previous_coin_bytes,
3415                off_chain_values,
3416                mode,
3417                // `dry_run` is `false` on a submit, `true` on a
3418                // validate-only call.
3419                context,
3420            ),
3421        )
3422        .await
3423        .map_err(TopicManagerError::Other)
3424        .and_then(|judged| judged)
3425    }
3426
3427    /// Whether the door may land a carried predecessor into `topic` (the
3428    /// E1D delta fold, L1): neither its manager nor any lookup service
3429    /// reads off-chain values there, which the landing does not have.
3430    fn lands_without_values(&self, topic: &str) -> bool {
3431        self.managers
3432            .get(topic)
3433            .is_some_and(|manager| !manager.reads_off_chain_values())
3434            && self
3435                .lookup_services
3436                .values()
3437                .all(|ls| !ls.reads_off_chain_values(topic))
3438    }
3439
3440    /// Run Phase 1 (topic validation) and Phase 2 (broadcast) without mutating storage.
3441    ///
3442    /// Returns (validations, steak, parsed_tx, txid) so the caller can either
3443    /// stop (validate-only) or proceed with Phase 3 (mutations).
3444    async fn run_validation(
3445        &self,
3446        tagged_beef: &TaggedBEEF,
3447        mode: SubmitMode,
3448        context: &TopicAdmittanceContext,
3449        bound: Option<&CallBound>,
3450        walked: Option<&HashSet<String>>,
3451        policy: WalkBreachPolicy,
3452    ) -> Result<Validated, EngineError> {
3453        // Validate all topics are supported
3454        for topic in &tagged_beef.topics {
3455            if !self.managers.contains_key(topic) {
3456                return Err(EngineError::UnsupportedTopic(topic.clone()));
3457            }
3458        }
3459
3460        // Parse the SUBJECT from the BEEF — order-independent (`subject.rs`,
3461        // loop-2 hardening 2026-09-05): `from_beef(_, None)` takes the last
3462        // tx in wire order, which an SDK-sorted INCOMPLETE ancestry fills
3463        // with a fully-sourced ancestor; the engine then judged a hop, admitted
3464        // nothing and the pot never entered the index while its JOIN mined.
3465        let subject = {
3466            let mut b =
3467                beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
3468                    .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3469            crate::subject::subject_txid_of(&mut b)
3470        };
3471        let tx = beef_limits::transaction_from_beef(
3472            &tagged_beef.beef,
3473            subject.as_deref(),
3474            &beef_limits::ENGINE_BEEF_LIMITS,
3475        )
3476        .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3477        let txid = tx.id();
3478
3479        // SPV verification, skipped ONLY for HistoricalTxNoSpv, exactly the
3480        // reference's `if (mode !== 'historical-tx-no-spv') tx.verify(...)`.
3481        // That mode exists for GASP `finalizeGraph`, whose graphs
3482        // `validateGraphAnchor` already verified, and since bsv-low #551 that
3483        // is TRUE here: `OverlayGASPStorage::validate_graph_anchor` runs this
3484        // same check ([`verify_spv_like_the_reference`], the same tracker and
3485        // the same switch) over the ROOT node's BEEF and replays the graph
3486        // through the topic manager before a single BEEF of it reaches this
3487        // door. Before #551 the anchor check was a no-op and this skip
3488        // admitted a peer's graph on structure alone.
3489        //
3490        // A predecessor the door lands first (`walked`, the E1D delta fold,
3491        // L4) is not walked again when this submit's walks already passed
3492        // over it, and its walk trusts what they passed over.
3493        //
3494        // The walk runs under the engine's budget (bsv-low #592,
3495        // [`Engine::set_walk_budget`]): one that could not run within it is
3496        // NOT a refusal (see `verify_beef_linear`), and NOT an admission
3497        // unless the caller holds the network's accept of these bytes (the
3498        // E592 lens fold, H1, [`WalkBreachPolicy`]): "not now" before any
3499        // manager is asked, or, under `NetworkAccepted`, this submit goes on
3500        // as under `HistoricalTxNoSpv` and the report says so.
3501        let mut could_not_run = None;
3502        if mode != SubmitMode::HistoricalTxNoSpv {
3503            match walked {
3504                Some(walked) if walked.contains(&txid) => {}
3505                trusted => {
3506                    could_not_run = verify_spv_trusting(
3507                        self.chain_tracker.as_deref(),
3508                        self.verify_scripts,
3509                        &tagged_beef.beef,
3510                        &txid,
3511                        trusted.unwrap_or(&HashSet::new()),
3512                        self.walk_budget,
3513                    )
3514                    .await?;
3515                }
3516            }
3517        }
3518        if let Some(stop) = could_not_run.take() {
3519            match policy {
3520                WalkBreachPolicy::NotNow => {
3521                    warn!(
3522                        "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): not now, nothing judged or written; present it proven (bsv-low #592, E592 fold)",
3523                        stop.subject_txid,
3524                        stop.limb.as_str(),
3525                        stop.at_txid,
3526                        stop.subject_judged,
3527                        stop.what
3528                    );
3529                    return Err(EngineError::WalkCouldNotRun(stop));
3530                }
3531                WalkBreachPolicy::NetworkAccepted => {
3532                    warn!(
3533                        "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): the network accepted these bytes, the submit goes on as historical-tx-no-spv (bsv-low #592)",
3534                        stop.subject_txid,
3535                        stop.limb.as_str(),
3536                        stop.at_txid,
3537                        stop.subject_judged,
3538                        stop.what
3539                    );
3540                    could_not_run = Some(stop);
3541                }
3542            }
3543        }
3544
3545        let mut steak = Steak::new();
3546
3547        // =============================================================
3548        // PHASE 1: VALIDATE (read-only)
3549        // =============================================================
3550        let mut validations = Vec::new();
3551
3552        for topic in &tagged_beef.topics {
3553            validations.push(
3554                self.validate_topic(
3555                    &tx,
3556                    &txid,
3557                    topic,
3558                    tagged_beef.off_chain_values.as_deref(),
3559                    mode,
3560                    context,
3561                    bound,
3562                )
3563                .await,
3564            );
3565        }
3566
3567        // Build preliminary STEAK
3568        for v in &validations {
3569            steak.insert(v.topic.clone(), v.admittance.clone());
3570        }
3571
3572        // =============================================================
3573        // PHASE 2: BROADCAST / SHIP propagation (before mutations)
3574        // =============================================================
3575        if mode == SubmitMode::CurrentTx {
3576            // ── ARC network broadcast ──────────────────────────────────
3577            // Broadcast to miners via ARC, matching the TS Engine pattern.
3578            // Skip if the tx already has a merkle path (already mined).
3579            //
3580            // INCIDENT D1-CALLBACK-FLOOD 2026-09-01: also skip when EVERY
3581            // topic classified the tx as a dupe (already applied) — the
3582            // exact `!v.is_dupe` filter the SHIP arm below has always had,
3583            // ten lines away. Without it, every RE-PRESENT of an
3584            // already-applied tx (cron ad-sync, peer crawl, a client
3585            // retrying a default-mode /submit) re-POSTed to Arcade and
3586            // re-registered the status callback, and registrations
3587            // accumulate per POST on Arcade's side — the flood's fuel. A
3588            // genuinely new tx (any topic non-dupe) broadcasts exactly as
3589            // before.
3590            let all_topics_dupe = !validations.is_empty() && validations.iter().all(|v| v.is_dupe);
3591            if let Some(ref arc) = self.arc_broadcaster {
3592                if all_topics_dupe {
3593                    info!(
3594                        "Skipping ARC broadcast — tx already applied to every topic (re-present)"
3595                    );
3596                } else if tx.merkle_path.is_none() {
3597                    let raw_tx_hex = tx.to_hex();
3598                    match arc.broadcast(&raw_tx_hex).await {
3599                        Ok(arc_txid) => {
3600                            info!("ARC broadcast succeeded: txid={arc_txid}");
3601                        }
3602                        Err(e) => {
3603                            error!("ARC broadcast failed (non-fatal): {e}");
3604                        }
3605                    }
3606                } else {
3607                    info!("Skipping ARC broadcast — tx already has merkle proof");
3608                }
3609            }
3610
3611            // ── SHIP peer propagation ──────────────────────────────────
3612            if let Some(ref broadcaster) = self.broadcaster {
3613                let relevant_topics: Vec<String> = validations
3614                    .iter()
3615                    .filter(|v| {
3616                        !v.is_dupe && !v.failed && !v.admittance.outputs_to_admit.is_empty()
3617                    })
3618                    .map(|v| v.topic.clone())
3619                    .collect();
3620
3621                if !relevant_topics.is_empty() {
3622                    if let Some(ship_ls) = self.lookup_services.get("ls_ship") {
3623                        for topic in &relevant_topics {
3624                            let question = LookupQuestion::new(
3625                                "ls_ship",
3626                                serde_json::json!({ "topics": [topic] }),
3627                            );
3628                            match ship_ls.lookup(&question).await {
3629                                Ok(LookupResult::OutputList(refs)) => {
3630                                    for reference in &refs {
3631                                        if let Ok(Some(output)) = self
3632                                            .storage
3633                                            .find_output(
3634                                                &reference.txid,
3635                                                reference.output_index,
3636                                                None,
3637                                                None,
3638                                                false,
3639                                            )
3640                                            .await
3641                                        {
3642                                            if let Some(domain) =
3643                                                parse_ship_domain_from_script(&output.output_script)
3644                                            {
3645                                                if let Some(ref our_url) = self.config.hosting_url {
3646                                                    if domain.trim_end_matches('/')
3647                                                        == our_url.trim_end_matches('/')
3648                                                    {
3649                                                        continue;
3650                                                    }
3651                                                }
3652                                                if let Err(e) = broadcaster
3653                                                    .broadcast_to_host(&domain, tagged_beef)
3654                                                    .await
3655                                                {
3656                                                    error!(
3657                                                        "SHIP propagation to {domain} failed for topic {topic}: {e}"
3658                                                    );
3659                                                }
3660                                            }
3661                                        }
3662                                    }
3663                                }
3664                                Ok(LookupResult::Answer(_)) => {
3665                                    // SHIP only ever returns OutputList; an Answer here means
3666                                    // a misconfigured replacement service. Skip rather than
3667                                    // attempt to extract refs from a freeform/formula payload.
3668                                    error!(
3669                                        "SHIP lookup for topic {topic} returned a pre-formed \
3670                                         LookupAnswer; expected OutputList. Skipping fanout."
3671                                    );
3672                                }
3673                                Err(e) => {
3674                                    error!("SHIP lookup for topic {topic} failed: {e}");
3675                                }
3676                            }
3677                        }
3678                    }
3679                }
3680            }
3681        }
3682
3683        Ok((validations, steak, tx, txid, could_not_run))
3684    }
3685
3686    // ========================================================================
3687    // Lookup
3688    // ========================================================================
3689
3690    /// Answer a lookup query.
3691    ///
3692    /// Delegates to the appropriate LookupService, then hydrates results with BEEF.
3693    ///
3694    /// When `history_selector` is provided, each output is hydrated with its
3695    /// ancestor spend chain (via `get_utxo_history`) before building the response.
3696    /// This matches the TS Engine behavior when a history selector is configured.
3697    pub async fn lookup(
3698        &self,
3699        question: &LookupQuestion,
3700        history_selector: Option<HistorySelector>,
3701    ) -> Result<LookupAnswer, EngineError> {
3702        Ok(self.lookup_with_txids(question, history_selector).await?.0)
3703    }
3704
3705    /// Same as [`Engine::lookup`], additionally returning each hydrated
3706    /// output's txid, aligned index-for-index with the `OutputList` items
3707    /// (empty for pre-formed `Answer`s).
3708    ///
3709    /// The txid IS the storage primary key the row was just fetched by —
3710    /// handing it to the caller lets the aggregated wire serializer write it
3711    /// directly instead of re-deriving it with a full BEEF parse plus a
3712    /// double-SHA256 per output (bsv-low #289).
3713    pub async fn lookup_with_txids(
3714        &self,
3715        question: &LookupQuestion,
3716        history_selector: Option<HistorySelector>,
3717    ) -> Result<(LookupAnswer, Vec<String>), EngineError> {
3718        let service = self
3719            .lookup_services
3720            .get(&question.service)
3721            .ok_or_else(|| EngineError::LookupServiceNotFound(question.service.clone()))?;
3722
3723        // Preserve WHOSE fault it was. A lookup service reports a malformed
3724        // query as `InvalidQuery` / `Unsupported`; stringifying both into
3725        // `LookupFailed` (as this did) threw that away and reported every
3726        // caller mistake as a 500.
3727        let result = service.lookup(question).await.map_err(|e| match e {
3728            LookupServiceError::InvalidQuery(m) | LookupServiceError::Unsupported(m) => {
3729                EngineError::InvalidQuery(m)
3730            }
3731            other => EngineError::LookupFailed(other.to_string()),
3732        })?;
3733
3734        // Two paths per LookupResult:
3735        // - OutputList(refs): the LS yields outpoints; we hydrate each with
3736        //   BEEF (and optional ancestor chain via history_selector) and
3737        //   assemble LookupAnswer::OutputList.
3738        // - Answer(answer): the LS already produced the full answer
3739        //   (Freeform/Formula); pass through verbatim. The Engine does NOT
3740        //   apply history-selector hydration to a pre-formed Answer — the
3741        //   LS is presumed to have embedded whatever ancestry it wants.
3742        let refs = match result {
3743            LookupResult::OutputList(refs) => refs,
3744            LookupResult::Answer(answer) => return Ok((answer, Vec::new())),
3745        };
3746
3747        // ONE batched hydration query for the whole result set instead of a
3748        // D1 round-trip per row (the #289 N+1). Order is preserved: the
3749        // batch contract returns outputs in input-outpoint order, skipping
3750        // outpoints with no stored row. A storage error is a REAL error now
3751        // (it used to be swallowed per-row into a silently-empty list — the
3752        // "vanishing table" failure mode): an uncertain read must never
3753        // masquerade as a confidently-empty lobby.
3754        let outpoints: Vec<Outpoint> = refs
3755            .iter()
3756            .map(|r| Outpoint::new(&r.txid, r.output_index))
3757            .collect();
3758        let found = self
3759            .storage
3760            .find_served_outputs(&outpoints)
3761            .await
3762            .map_err(|e| EngineError::StorageError(e.to_string()))?;
3763
3764        let mut outputs = Vec::new();
3765        let mut txids = Vec::new();
3766        for output in found {
3767            // If history selector provided, hydrate ancestor chain
3768            let final_output = if history_selector.is_some() {
3769                match self
3770                    .get_utxo_history(&output, history_selector.clone())
3771                    .await
3772                {
3773                    Ok(Some(hydrated)) => hydrated,
3774                    _ => output,
3775                }
3776            } else {
3777                output
3778            };
3779
3780            // Skip an output whose BEEF is missing OR empty: a lookup item
3781            // without decodable BEEF is useless to the client (it can only
3782            // drop it), and returning an empty-BEEF row is what made a fresh
3783            // LOW table show up in a query yet be undecodable/invisible to
3784            // opponents (the "vanishing table" — 2026-07-11). Only ever
3785            // return fully-hydrated, decodable outputs.
3786            if let Some(beef) = final_output.beef {
3787                if !beef.is_empty() {
3788                    txids.push(final_output.txid.clone());
3789                    outputs.push(OutputListItem {
3790                        beef,
3791                        output_index: final_output.output_index,
3792                        context: None,
3793                    });
3794                }
3795            }
3796        }
3797
3798        Ok((LookupAnswer::OutputList { outputs }, txids))
3799    }
3800
3801    // ========================================================================
3802    // Metadata
3803    // ========================================================================
3804
3805    // ========================================================================
3806    // UTXO History
3807    // ========================================================================
3808
3809    /// Traverse and return the history of a UTXO.
3810    ///
3811    /// If no `history_selector` is provided, returns the output as-is.
3812    /// If a depth selector is provided, includes up to N levels of ancestor
3813    /// transactions by embedding them as source_transactions in the BEEF.
3814    pub async fn get_utxo_history(
3815        &self,
3816        output: &Output,
3817        history_selector: Option<HistorySelector>,
3818    ) -> Result<Option<Output>, EngineError> {
3819        let Some(selector) = history_selector else {
3820            return Ok(Some(output.clone()));
3821        };
3822
3823        // Verify BEEF exists before attempting hydration
3824        if output.beef.is_none() {
3825            return Err(EngineError::Other(
3826                "Output must have associated transaction BEEF!".into(),
3827            ));
3828        }
3829
3830        match self.hydrate_utxo_history(output, &selector, 0).await {
3831            Ok(Some(hydrated_output)) => Ok(Some(hydrated_output)),
3832            Ok(None) => Ok(None),
3833            Err(e) => Err(EngineError::Other(format!(
3834                "Error retrieving UTXO history: {e}"
3835            ))),
3836        }
3837    }
3838
3839    /// Recursive UTXO history hydration.
3840    fn hydrate_utxo_history<'a>(
3841        &'a self,
3842        output: &'a Output,
3843        selector: &'a HistorySelector,
3844        current_depth: u32,
3845    ) -> std::pin::Pin<
3846        Box<dyn std::future::Future<Output = Result<Option<Output>, EngineError>> + 'a>,
3847    > {
3848        Box::pin(async move {
3849            let beef_data = output.beef.as_ref().ok_or_else(|| {
3850                EngineError::Other("Output must have associated transaction BEEF!".into())
3851            })?;
3852
3853            // Check if we should traverse at this depth
3854            let should_traverse = match selector {
3855                HistorySelector::Depth(max_depth) => current_depth <= *max_depth,
3856            };
3857
3858            if !should_traverse {
3859                return Ok(None);
3860            }
3861
3862            // Start from the STORED BEEF, not from a re-parsed subject.
3863            //
3864            // #4: this used to be `Transaction::from_beef(beef_data, None)` followed
3865            // by `tx.to_beef(true)`, which keeps only the subject transaction and
3866            // therefore DESTROYS the stored BUMPs. Measured on prod before the fix:
3867            // `ls_low` byGameId `73de6e48…` returned 1,172 B / 1 tx / 1 bump (PROVEN)
3868            // with no header, and 676 B / 1 tx / 0 bumps with `x-history-depth: 3` —
3869            // a caller asking for MORE provenance got back a BEEF no wallet can
3870            // verify, silently, behind a 200.
3871            //
3872            // Accumulating into the stored BEEF fixes both halves: its own bumps
3873            // survive, and so does any ancestry that arrived inside the submitted
3874            // bytes but was never admitted as an output of its own (a foreign
3875            // parent), which the `outputs_consumed` walk below cannot see.
3876            let mut acc = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3877                .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3878
3879            // For each consumed output, recursively hydrate and embed as source transaction
3880            for consumed in &output.outputs_consumed {
3881                if let Ok(Some(child_output)) = self
3882                    .storage
3883                    .find_output(&consumed.txid, consumed.output_index, None, None, true)
3884                    .await
3885                {
3886                    if let Ok(Some(hydrated_child)) = self
3887                        .hydrate_utxo_history(&child_output, selector, current_depth + 1)
3888                        .await
3889                    {
3890                        // MERGE the hydrated ancestor in, rather than embedding it
3891                        // as a `source_transaction` on a re-parsed subject: a merge
3892                        // carries the child's own BUMPs across, which is the whole
3893                        // point of asking for history. An unparseable child is
3894                        // skipped — never allowed to poison the accumulator.
3895                        if let Some(ref child_beef) = hydrated_child.beef {
3896                            if let Ok(child) = Beef::from_binary(child_beef) {
3897                                acc.merge_beef(&child);
3898                            }
3899                        }
3900                    }
3901                }
3902            }
3903
3904            Ok(Some(Output {
3905                beef: Some(acc.to_binary()),
3906                ..output.clone()
3907            }))
3908        })
3909    }
3910
3911    // ========================================================================
3912    // Metadata
3913    // ========================================================================
3914
3915    pub async fn list_topic_managers(&self) -> HashMap<String, ServiceMetadata> {
3916        let mut result = HashMap::new();
3917        for (name, manager) in &self.managers {
3918            let meta = manager.get_metadata().await;
3919            result.insert(name.clone(), meta);
3920        }
3921        result
3922    }
3923
3924    /// List all registered lookup services with their metadata.
3925    pub async fn list_lookup_service_providers(&self) -> HashMap<String, ServiceMetadata> {
3926        let mut result = HashMap::new();
3927        for (name, service) in &self.lookup_services {
3928            let meta = service.get_metadata().await;
3929            result.insert(name.clone(), meta);
3930        }
3931        result
3932    }
3933
3934    /// Get documentation for a specific topic manager.
3935    pub async fn get_documentation_for_topic_manager(&self, name: &str) -> String {
3936        match self.managers.get(name) {
3937            Some(manager) => manager.get_documentation().await,
3938            None => "No documentation found!".to_string(),
3939        }
3940    }
3941
3942    /// Get documentation for a specific lookup service.
3943    pub async fn get_documentation_for_lookup_service(&self, name: &str) -> String {
3944        match self.lookup_services.get(name) {
3945            Some(service) => service.get_documentation().await,
3946            None => "No documentation found!".to_string(),
3947        }
3948    }
3949
3950    // ========================================================================
3951    // GASP endpoints
3952    // ========================================================================
3953
3954    /// Respond to a GASP initial sync request.
3955    ///
3956    /// Returns UTXOs for the given topic since the requested score.
3957    ///
3958    /// The caller-supplied `limit` is CLAMPED to
3959    /// [`Engine::SYNC_RESPONSE_MAX_LIMIT`] (and defaulted when absent):
3960    /// `/requestSyncResponse` is a PUBLIC route, and an absent limit used to
3961    /// mean an unbounded scan of the outputs table (bsv-low #291). Bounding
3962    /// is lossless for sync correctness because the initiator
3963    /// ([`crate::gasp::GASPSync`]) pages for as long as its `since` score
3964    /// cursor advances — a truncated page is fetched by its NEXT request
3965    /// within the same run (gate finding M1: pagination must not demand a
3966    /// full page, which this clamp can make impossible).
3967    pub async fn provide_foreign_sync_response(
3968        &self,
3969        request: &GASPInitialRequest,
3970        topic: &str,
3971    ) -> Result<GASPInitialResponse, EngineError> {
3972        let limit = Self::clamp_sync_limit(request.limit);
3973        let outputs = self
3974            .storage
3975            .find_utxos_for_topic(topic, Some(request.since as f64), Some(limit), false)
3976            .await
3977            .map_err(|e| EngineError::StorageError(e.to_string()))?;
3978
3979        Ok(GASPInitialResponse {
3980            utxo_list: outputs
3981                .iter()
3982                .map(|o| GASPOutput {
3983                    txid: o.txid.clone(),
3984                    output_index: o.output_index,
3985                    score: o.score.unwrap_or(0.0),
3986                })
3987                .collect(),
3988            since: request.since,
3989        })
3990    }
3991
3992    /// Provide a GASPNode for a specific transaction within a graph.
3993    ///
3994    /// Searches the BEEF tree of the root output (identified by graphID) for
3995    /// the requested txid. If not found in the BEEF tree, recurses through
3996    /// outputsConsumed in storage.
3997    pub async fn provide_foreign_gasp_node(
3998        &self,
3999        graph_id: &str,
4000        txid: &str,
4001        output_index: u32,
4002    ) -> Result<GASPNode, EngineError> {
4003        let root_outpoint = Outpoint::from_graph_id(graph_id)
4004            .ok_or_else(|| EngineError::Other(format!("Invalid graphID: {graph_id}")))?;
4005
4006        let root_output = self
4007            .storage
4008            .find_output(
4009                &root_outpoint.txid,
4010                root_outpoint.output_index,
4011                None,
4012                None,
4013                true,
4014            )
4015            .await
4016            .map_err(|e| EngineError::StorageError(e.to_string()))?
4017            .ok_or(EngineError::NodeNotFound)?;
4018
4019        self.hydrate_gasp_node(&root_output, graph_id, txid, output_index)
4020            .await
4021    }
4022
4023    /// Recursively search a BEEF tree for a specific txid and return a GASPNode.
4024    fn hydrate_gasp_node<'a>(
4025        &'a self,
4026        output: &'a Output,
4027        graph_id: &'a str,
4028        txid: &'a str,
4029        output_index: u32,
4030    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GASPNode, EngineError>> + 'a>>
4031    {
4032        Box::pin(async move {
4033            let beef_data = output.beef.as_ref().ok_or(EngineError::NodeNotFound)?;
4034
4035            // The stored body's SUBJECT by the same order-independent rule
4036            // (`subject.rs`) — a plain stored body's last tx can be an ancestor.
4037            let root = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
4038                .ok()
4039                .and_then(|mut b| crate::subject::subject_txid_of(&mut b));
4040            let root_tx = beef_limits::transaction_from_beef(
4041                beef_data,
4042                root.as_deref(),
4043                &beef_limits::STORED_BEEF_LIMITS,
4044            )
4045            .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
4046
4047            // Search the transaction tree for the requested txid
4048            if let Some(node) = Self::search_tx_tree(&root_tx, graph_id, txid, output_index) {
4049                return Ok(node);
4050            }
4051
4052            // Fallback: try parsing BEEF with the target txid directly
4053            // (some BEEF structures have the tx in the list but not linked as source_transaction)
4054            if let Some(node) = Self::search_beef_for_txid(beef_data, graph_id, txid, output_index)
4055            {
4056                return Ok(node);
4057            }
4058
4059            // Not found in BEEF tree — recurse through outputsConsumed in storage
4060            for consumed in &output.outputs_consumed {
4061                if let Ok(Some(consumed_output)) = self
4062                    .storage
4063                    .find_output(&consumed.txid, consumed.output_index, None, None, true)
4064                    .await
4065                {
4066                    if let Ok(node) = self
4067                        .hydrate_gasp_node(&consumed_output, graph_id, txid, output_index)
4068                        .await
4069                    {
4070                        return Ok(node);
4071                    }
4072                }
4073            }
4074
4075            // The graph root is known but neither its BEEF nor anything it
4076            // consumed holds the requested transaction. That is a definite
4077            // "not held", the same answer as an unknown root: the reference
4078            // throws `Unable to find output associated with your request!`
4079            // here and its route answers 400. A syncing peer reads the 400 as
4080            // `GASPError::NodeNotFound` and prunes a decoy input on it (the D8
4081            // rule, `GASPSync::process_incoming_node`); a 500 would read as a
4082            // fault of the moment and strand the chain behind the decoy.
4083            Err(EngineError::NodeNotFound)
4084        })
4085    }
4086
4087    /// Search a Transaction tree (via sourceTransaction links) for a specific txid.
4088    fn search_tx_tree(
4089        tx: &Transaction,
4090        graph_id: &str,
4091        target_txid: &str,
4092        output_index: u32,
4093    ) -> Option<GASPNode> {
4094        let current_txid = tx.id();
4095
4096        if current_txid == target_txid {
4097            let mut node = GASPNode {
4098                graph_id: graph_id.to_string(),
4099                raw_tx: tx.to_hex(),
4100                output_index,
4101                proof: None,
4102                tx_metadata: None,
4103                output_metadata: None,
4104                inputs: None,
4105            };
4106            if let Some(ref merkle_path) = tx.merkle_path {
4107                node.proof = Some(merkle_path.to_hex());
4108            }
4109            return Some(node);
4110        }
4111
4112        // Recurse into inputs via source_transaction
4113        for input in &tx.inputs {
4114            if let Some(ref source_tx) = input.source_transaction {
4115                if let Some(node) =
4116                    Self::search_tx_tree(source_tx, graph_id, target_txid, output_index)
4117                {
4118                    return Some(node);
4119                }
4120            }
4121        }
4122
4123        None
4124    }
4125
4126    /// Alternative search: try parsing the BEEF with the target txid directly.
4127    /// Some BEEF structures may not link source_transaction on inputs but
4128    /// still contain the transaction in the BEEF's transaction list.
4129    fn search_beef_for_txid(
4130        beef_data: &[u8],
4131        graph_id: &str,
4132        target_txid: &str,
4133        output_index: u32,
4134    ) -> Option<GASPNode> {
4135        // Try parsing BEEF with the specific txid
4136        if let Ok(tx) = beef_limits::transaction_from_beef(
4137            beef_data,
4138            Some(target_txid),
4139            &beef_limits::STORED_BEEF_LIMITS,
4140        ) {
4141            if tx.id() == target_txid {
4142                let mut node = GASPNode {
4143                    graph_id: graph_id.to_string(),
4144                    raw_tx: tx.to_hex(),
4145                    output_index,
4146                    proof: None,
4147                    tx_metadata: None,
4148                    output_metadata: None,
4149                    inputs: None,
4150                };
4151                if let Some(ref merkle_path) = tx.merkle_path {
4152                    node.proof = Some(merkle_path.to_hex());
4153                }
4154                return Some(node);
4155            }
4156        }
4157        None
4158    }
4159
4160    // ========================================================================
4161    // Merkle proof handling
4162    // ========================================================================
4163
4164    /// Handle a new merkle proof for a transaction.
4165    ///
4166    /// When a transaction gets mined, ARC calls back with the merkle proof.
4167    /// This method:
4168    /// 1. Finds all outputs for the txid in storage
4169    /// 2. Parses the BEEF, updates the merkle path in the transaction tree
4170    /// 3. Serializes updated BEEF back to storage
4171    /// 4. Recursively updates the consumedBy chain (outputs that spent this one)
4172    /// 5. Updates blockHeight on the outputs
4173    pub async fn handle_new_merkle_proof(
4174        &self,
4175        txid: &str,
4176        proof_hex: &str,
4177        block_height: Option<u32>,
4178    ) -> Result<(), EngineError> {
4179        let outputs = self
4180            .storage
4181            .find_outputs_for_transaction(txid, true)
4182            .await
4183            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4184
4185        if outputs.is_empty() {
4186            return Err(EngineError::Other(
4187                "Could not find matching transaction outputs for proof ingest!".into(),
4188            ));
4189        }
4190
4191        // Parse merkle proof if provided
4192        let proof = if proof_hex.is_empty() {
4193            None
4194        } else {
4195            Some(
4196                beef_limits::merkle_path_from_hex(proof_hex, beef_limits::PUSH_PROOF_MAX_BYTES)
4197                    .map_err(|e| EngineError::Other(format!("Invalid merkle proof hex: {e}")))?,
4198            )
4199        };
4200
4201        for output in &outputs {
4202            // Stitch the proof into the STORED BEEF (#284) — never rebuild it
4203            // around a re-picked subject; see stitch_proof_into_stored_beef.
4204            if let (Some(ref beef_data), Some(ref proof)) = (&output.beef, &proof) {
4205                if let Some(new_beef) = Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4206                {
4207                    let _ = self
4208                        .storage
4209                        .update_transaction_beef(&output.txid, &new_beef)
4210                        .await;
4211                }
4212            }
4213
4214            // Update block height
4215            if let Some(height) = block_height {
4216                let _ = self
4217                    .storage
4218                    .update_output_block_height(
4219                        &output.txid,
4220                        output.output_index,
4221                        &output.topic,
4222                        height,
4223                    )
4224                    .await;
4225            }
4226
4227            // Recursively update consumedBy chain
4228            for consuming in &output.consumed_by {
4229                if let Ok(consumed_outputs) = self
4230                    .storage
4231                    .find_outputs_for_transaction(&consuming.txid, true)
4232                    .await
4233                {
4234                    for consumed_output in &consumed_outputs {
4235                        // The consuming row's BEEF carries this tx as an
4236                        // ancestor — stitch the same bump in there too, again
4237                        // without rebuilding the tx set (#284: this branch is
4238                        // the one that was observed replacing a head-spend's
4239                        // stored BEEF with parent ancestry only).
4240                        if let (Some(ref beef_data), Some(ref proof)) =
4241                            (&consumed_output.beef, &proof)
4242                        {
4243                            if let Some(new_beef) =
4244                                Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4245                            {
4246                                let _ = self
4247                                    .storage
4248                                    .update_transaction_beef(&consumed_output.txid, &new_beef)
4249                                    .await;
4250                            }
4251                        }
4252                    }
4253                }
4254            }
4255        }
4256
4257        Ok(())
4258    }
4259
4260    /// Complete missing merkle proofs for stored, confirmed transactions.
4261    ///
4262    /// The `/submit`-admitted ingest path stores a proofless BEEF for each
4263    /// transaction (the submitter does not yet have a merkle proof at submit
4264    /// time). When such a transaction later mines, nothing in the overlay
4265    /// re-fetches its proof, so its stored BEEF stays proofless forever — and a
4266    /// frontend that trims `inputBEEF` to `{source tx + its BUMP}` for a P2PKH
4267    /// payment-import spend has no BUMP to trim to, hanging the import (#130).
4268    ///
4269    /// This pass closes that gap from chain (the one blessed server-side WoC
4270    /// use). It:
4271    /// 1. Pulls a bounded page (`limit`) of stored `(txid, beef)` from storage.
4272    /// 2. Parses each BEEF and keeps only those whose target tx still lacks a
4273    ///    merkle proof (`Beef::find_txid(txid).has_proof() == false`).
4274    /// 3. For each proofless candidate, fetches its BUMP via the configured
4275    ///    [`AncestorFetcher`](crate::gasp::AncestorFetcher). If a proof comes
4276    ///    back, it calls [`Self::handle_new_merkle_proof`], which stitches the
4277    ///    BUMP into the stored BEEF (D1 + R2 mirror), updates output block
4278    ///    height, and recurses the `consumedBy` chain.
4279    /// 4. Skips (does NOT error) txs the fetcher can't prove yet (still
4280    ///    unconfirmed) — those are simply retried on a later tick.
4281    ///
4282    /// **No-op when no [`AncestorFetcher`] is configured** — production-safe
4283    /// default (the same opt-in switch as GASP ancestor hydration). Bounded by
4284    /// `limit` (a per-tick budget, like the platform fetcher's own budget).
4285    ///
4286    /// `min_age_secs` is the PUSH-PRIMARY BACKSTOP gate (bsv-low #228 /
4287    /// arcade#259): since the Arcade MINED webhook (`/arc-ingest`) pushes a
4288    /// verified proof ~150 ms after a tx mines, this poll pass is a BACKSTOP,
4289    /// not the primary source — rows stored less than `min_age_secs` ago are
4290    /// skipped entirely (their push is still expected). Pass `0` to disable
4291    /// the gate (poll everything — the pre-#228 behaviour, and the degradation
4292    /// mode if pushes stop: an old-enough row is ALWAYS polled, so webhook
4293    /// loss degrades to polling, never to nothing). Unknown-age rows are
4294    /// always eligible (fail-safe — see the storage-trait doc).
4295    pub async fn complete_missing_proofs(
4296        &self,
4297        limit: u64,
4298        min_age_secs: u64,
4299    ) -> Result<ProofCompletionSummary, EngineError> {
4300        let Some(fetcher) = self.ancestor_fetcher.as_deref() else {
4301            return Ok(ProofCompletionSummary::default());
4302        };
4303        self.complete_missing_proofs_with(fetcher, limit, min_age_secs)
4304            .await
4305    }
4306
4307    /// Complete stored transactions using the caller's dedicated allowance.
4308    /// The engine's other passes retain their configured ancestor courier.
4309    pub async fn complete_missing_proofs_with(
4310        &self,
4311        fetcher: &dyn crate::gasp::AncestorFetcher,
4312        limit: u64,
4313        min_age_secs: u64,
4314    ) -> Result<ProofCompletionSummary, EngineError> {
4315        let mut summary = ProofCompletionSummary::default();
4316
4317        let candidates = self
4318            .storage
4319            .find_transactions_for_proof_check(limit, min_age_secs)
4320            .await
4321            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4322        summary.scanned = candidates.len();
4323
4324        for cand in candidates {
4325            summary.early_gasp += usize::from(cand.early_gasp);
4326            // Read the stored BEEF for the subject's OWN bump by streaming
4327            // folds (one element in hand, no `Beef` built: NL-6); skip
4328            // anything unparseable (don't error the whole pass on one bad row).
4329            let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4330                Ok(b) => b,
4331                Err(e) => {
4332                    warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4333                    continue;
4334                }
4335            };
4336
4337            // Already structurally proven? The stored BEEF carries a merkle bump
4338            // for the target tx, but its backend `has_proof` flag may still be 0
4339            // — EITHER a legitimate GASP-synced proof written before overlay
4340            // migration 0010 (which defaulted every existing row to 0), OR an
4341            // admit-time bump that was NEVER SPV-verified (`/submit` skips SPV on
4342            // historical topics) or is outright forged. Serve-time BEEF trimming
4343            // trusts the `has_proof` flag, so latching one on STRUCTURE ALONE
4344            // would let a forged bump be trimmed on (#192/#193 MEDIUM). Re-verify
4345            // the STORED bump against chaintracks (via the fetcher — its header
4346            // source is the only arbiter of a merkle root). Genuine → latch
4347            // (fast, no re-fetch). Otherwise DON'T trust it: fall through to the
4348            // fetch+stitch path below, which overwrites the bump with a
4349            // chaintracks-verified one (or leaves the row proofless for retry).
4350            // Fail-closed throughout.
4351            // the tx's OWN bump (its `bump_index`), never `find_bump`
4352            // (the FIRST bump containing the txid — the stale one after a
4353            // same-height reorg; review HIGH-1).
4354            if let Some(bump_hex) = own_bump
4355                .as_ref()
4356                .map(bsv_rs::transaction::MerklePath::to_hex)
4357            {
4358                if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4359                    // Idempotent + best-effort: a failure here is logged, not
4360                    // fatal — the row simply lingers one more tick.
4361                    // bsv-low M19 R2 round 3 (review MED-2): latch WITH the
4362                    // anchor height (from the tx's OWN verified bump) so the
4363                    // revalidation sweep's transactions leg can window this
4364                    // row — `mark_transaction_proven` alone left every
4365                    // fast-path row anchorless, in no window forever.
4366                    let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4367                        .ok()
4368                        .map(|mp| u64::from(mp.block_height));
4369                    if let Err(e) = self
4370                        .storage
4371                        .mark_transaction_proven_at(&cand.txid, height)
4372                        .await
4373                    {
4374                        warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4375                    } else {
4376                        summary.already_proven += 1;
4377                    }
4378                    continue;
4379                }
4380                warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4381            }
4382            // Unverifiable structural bump → treat as proofless (fall through).
4383            summary.proofless += 1;
4384
4385            // Fetch this tx's own VERIFIED bump from chain — PROOF ONLY (the raw
4386            // is already in the stored BEEF, so no redundant raw fetch; #192/#193
4387            // FIX 2). A tx with no verified proof yet (still unconfirmed, or an
4388            // unverifiable/forged courier response) is skipped, not errored.
4389            let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4390                summary.still_unconfirmed += 1;
4391                continue;
4392            };
4393
4394            // Pull the block height out of the BUMP so the output rows update too.
4395            let block_height =
4396                beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4397                    .ok()
4398                    .map(|mp| mp.block_height);
4399
4400            match self
4401                .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4402                .await
4403            {
4404                Ok(()) => summary.completed += 1,
4405                Err(e) => {
4406                    warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4407                    summary.stitch_failed += 1;
4408                }
4409            }
4410        }
4411
4412        info!(
4413            scanned = summary.scanned,
4414            proofless = summary.proofless,
4415            completed = summary.completed,
4416            still_unconfirmed = summary.still_unconfirmed,
4417            fetch_failed = summary.fetch_failed,
4418            stitch_failed = summary.stitch_failed,
4419            already_proven = summary.already_proven,
4420            early_gasp = summary.early_gasp,
4421            "[PROOF COMPLETION] pass complete"
4422        );
4423
4424        Ok(summary)
4425    }
4426
4427    /// Recursively update merkle paths in a transaction's input tree.
4428    ///
4429    /// If the transaction's id matches txid, set its merkle_path.
4430    /// Otherwise, recurse into sourceTransactions of each input.
4431    /// Stitch a verified BUMP for `txid` into a STORED BEEF, preserving every
4432    /// transaction and every other proof in it (#284).
4433    ///
4434    /// The old path round-tripped the stored bytes through
4435    /// `Transaction::from_beef(bytes, None)` -> `to_beef(true)`, and
4436    /// `from_beef(_, None)` picks **`txs.last()`** — it ignores the atomic
4437    /// subject pointer entirely. Whenever the subject was not the last tx in
4438    /// wire order, a PARENT was picked, `to_beef(true)` then serialized that
4439    /// parent's world, and the rewrite dropped the subject transaction from its
4440    /// own stored BEEF — observed live on every pf head-spend (list / reprice /
4441    /// delist / buy) the moment it mined, breaking the NEXT spend on that name
4442    /// (zanaadu#284). `update_input_proofs`' "already has a proof — update it"
4443    /// arm could even stamp the child's bump onto that mis-picked parent.
4444    /// This is the same destroy-the-stored-BEEF class the #4 fix note above
4445    /// (`hydrate_utxo_history`) already documents for lookup hydration.
4446    ///
4447    /// Operating on [`Beef`] directly has none of those failure modes:
4448    /// `merge_bump` dedupes against existing bumps by (height, root) and
4449    /// assigns the bump ONLY to transactions that appear as flagged txid
4450    /// leaves, so stitching tx X's proof can never mislabel tx Y, and the tx
4451    /// set is never rebuilt at all.
4452    ///
4453    /// Returns `None` when the bytes do not parse or `txid` is not in this
4454    /// BEEF (nothing to stitch — e.g. a consuming row whose ancestry got
4455    /// trimmed), so callers skip the write instead of writing garbage.
4456    ///
4457    /// bsv-low M19 R2 round 3 (review HIGH-1): a same-height DIFFERENT-root
4458    /// proof (the 2026-09-07 reorg shape) does NOT combine in `merge_bump`
4459    /// (the roots differ) — it is pushed as a SECOND bump, and
4460    /// `update_bump_indices` assigns it only to txs whose `bump_index` is
4461    /// `None`, so the reorged subject keeps its index on the STALE bump.
4462    /// Left alone, `has_proof`/`proofHeight` and every `find_txid(txid)
4463    /// .bump_index()` reader then keep pointing at the orphan bump, and the
4464    /// row refutes again next sweep pass forever. So after the merge, FORCE
4465    /// the subject onto the bump that actually proves it, then drop any bump
4466    /// no transaction references (the orphan's, now unreferenced).
4467    pub fn stitch_proof_into_stored_beef(
4468        stored: &[u8],
4469        txid: &str,
4470        proof: &bsv_rs::transaction::MerklePath,
4471    ) -> Option<Vec<u8>> {
4472        let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4473        beef.find_txid(txid)?;
4474        let i = beef.merge_bump(proof.clone());
4475        // Does the merged bump actually prove `txid` (a flagged txid leaf)?
4476        // (It always does for a completion proof; the guard keeps a stray
4477        // caller from mis-anchoring a subject onto an ancestor's bump.)
4478        let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4479            b.path.first().is_some_and(|leaves| {
4480                leaves
4481                    .iter()
4482                    .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4483            })
4484        });
4485        if proves_subject {
4486            if let Some(tx) = beef.find_txid_mut(txid) {
4487                tx.set_bump_index(Some(i));
4488            }
4489        }
4490        Self::gc_unreferenced_bumps(&mut beef);
4491        Some(beef.to_binary())
4492    }
4493
4494    /// Drop every BUMP no transaction references and re-index the survivors
4495    /// (bsv-low M19 R2 round 3, review HIGH-1): after the subject is moved
4496    /// off an orphan bump, that bump is usually dead weight the next reader
4497    /// would still `find_bump`. A bump still referenced by ANOTHER tx (a
4498    /// sibling mined in the same block) is kept.
4499    fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4500        let referenced: std::collections::HashSet<usize> = beef
4501            .txs
4502            .iter()
4503            .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4504            .collect();
4505        if referenced.len() == beef.bumps.len() {
4506            return;
4507        }
4508        let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4509        let mut kept = Vec::with_capacity(referenced.len());
4510        for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4511            if referenced.contains(&old) {
4512                remap[old] = Some(kept.len());
4513                kept.push(bump);
4514            }
4515        }
4516        beef.bumps = kept;
4517        for tx in &mut beef.txs {
4518            if let Some(old) = tx.bump_index() {
4519                tx.set_bump_index(remap[old]);
4520            }
4521        }
4522    }
4523
4524    // ========================================================================
4525    // Advertisement sync
4526    // ========================================================================
4527
4528    /// Sync SHIP/SLAP advertisements with configured managers and services.
4529    ///
4530    /// Creates missing advertisements and revokes stale ones. Returns a
4531    /// [`SyncAdvertisementsReport`] so a create/submit failure is VISIBLE to
4532    /// the caller (bsv-low #320 defect 3a): this method used to swallow both
4533    /// into `error!` and return `Ok(())`, so `/admin/syncAdvertisements`
4534    /// reported `success` while zero advertisements were admitted locally —
4535    /// a caller could not tell a converged no-op from a silent failure, and
4536    /// every cycle re-created the full ad set.
4537    pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4538        let mut report = SyncAdvertisementsReport::default();
4539
4540        let Some(advertiser) = &self.advertiser else {
4541            return Ok(report); // No advertiser configured
4542        };
4543
4544        let hosting_url = match &self.config.hosting_url {
4545            Some(url) if !url.is_empty() => url.clone(),
4546            _ => return Ok(report), // No hosting URL
4547        };
4548
4549        // Get configured topics and services
4550        let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4551        let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4552
4553        if self.config.suppress_default_sync_advertisements {
4554            configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4555            configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4556        }
4557
4558        // Fetch current advertisements. A read failure REFUSES creation
4559        // (#320 M2): swallowing it into "current ads = none" made a
4560        // transient D1/storage fault re-create — and re-pay for — the
4561        // entire advertisement set, reported as a legitimate `to_create`.
4562        let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4563            Ok(ads) => ads,
4564            Err(e) => {
4565                error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4566                report.lookup_error = Some(format!("ship: {e}"));
4567                return Ok(report);
4568            }
4569        };
4570        let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4571            Ok(ads) => ads,
4572            Err(e) => {
4573                error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4574                report.lookup_error = Some(format!("slap: {e}"));
4575                return Ok(report);
4576            }
4577        };
4578
4579        // Determine what to create
4580        let ships_to_create: Vec<AdvertisementData> = configured_topics
4581            .iter()
4582            .filter(|topic| {
4583                !current_ship
4584                    .iter()
4585                    .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4586            })
4587            .map(|topic| AdvertisementData {
4588                protocol: Protocol::Ship,
4589                topic_or_service_name: topic.clone(),
4590            })
4591            .collect();
4592
4593        let slaps_to_create: Vec<AdvertisementData> = configured_services
4594            .iter()
4595            .filter(|service| {
4596                !current_slap
4597                    .iter()
4598                    .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4599            })
4600            .map(|service| AdvertisementData {
4601                protocol: Protocol::Slap,
4602                topic_or_service_name: service.clone(),
4603            })
4604            .collect();
4605
4606        // Determine what to revoke
4607        let ships_to_revoke: Vec<Advertisement> = current_ship
4608            .into_iter()
4609            .filter(|a| !configured_topics.contains(&a.topic_or_service))
4610            .collect();
4611
4612        let slaps_to_revoke: Vec<Advertisement> = current_slap
4613            .into_iter()
4614            .filter(|a| !configured_services.contains(&a.topic_or_service))
4615            .collect();
4616
4617        // Create new advertisements
4618        let mut all_to_create = ships_to_create;
4619        all_to_create.extend(slaps_to_create);
4620        report.to_create = all_to_create.len();
4621        if !all_to_create.is_empty() {
4622            match advertiser.create_advertisements(&all_to_create).await {
4623                Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4624                    Ok(steak) => {
4625                        for (topic, instructions) in &steak {
4626                            report
4627                                .admitted
4628                                .insert(topic.clone(), instructions.outputs_to_admit.len());
4629                        }
4630                    }
4631                    Err(e) => {
4632                        error!("Failed to submit new advertisements: {e}");
4633                        report.submit_error = Some(e.to_string());
4634                    }
4635                },
4636                Err(e) => {
4637                    error!("Failed to create advertisements: {e}");
4638                    report.create_error = Some(e.to_string());
4639                }
4640            }
4641        }
4642
4643        // Revoke stale advertisements
4644        let mut all_to_revoke = ships_to_revoke;
4645        all_to_revoke.extend(slaps_to_revoke);
4646        report.to_revoke = all_to_revoke.len();
4647        if !all_to_revoke.is_empty() {
4648            match advertiser.revoke_advertisements(&all_to_revoke).await {
4649                // An advertiser that declines to build a revocation (e.g. the
4650                // CF advertiser's documented v1 no-op) returns an EMPTY
4651                // TaggedBEEF — submitting it would only manufacture a parse
4652                // error. Skip, but say so in the report (#320 L2): the stale
4653                // ads stay on-chain until they age out.
4654                Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4655                    report.revoke_skipped = all_to_revoke.len();
4656                }
4657                Ok(tagged_beef) => {
4658                    if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4659                        error!("Failed to submit revocation: {e}");
4660                        report.revoke_submit_error = Some(e.to_string());
4661                    }
4662                }
4663                Err(e) => {
4664                    error!("Failed to revoke advertisements: {e}");
4665                    report.revoke_error = Some(e.to_string());
4666                }
4667            }
4668        }
4669
4670        Ok(report)
4671    }
4672
4673    // ========================================================================
4674    // Deep deletion
4675    // ========================================================================
4676
4677    /// Recursively delete a UTXO and all stale consumed inputs.
4678    ///
4679    /// Only deletes if the output has no remaining consumers (consumedBy is empty).
4680    /// Then recurses into outputsConsumed, removing the deleted output from their
4681    /// consumedBy lists and deleting them if they become unreferenced.
4682    #[allow(dead_code)] // Used by submit() once BEEF parsing is wired up
4683    ///
4684    /// `bound` is a finalize submit's [`CallBound`] over each call here
4685    /// (bsv-low #559); `None` everywhere else.
4686    fn delete_utxo_deep<'a>(
4687        &'a self,
4688        output: &'a Output,
4689        bound: Option<&'a CallBound>,
4690    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4691        Box::pin(async move {
4692            // Only delete if nothing consumes this output
4693            if output.consumed_by.is_empty() {
4694                stored(
4695                    bound,
4696                    self.storage
4697                        .delete_output(&output.txid, output.output_index, &output.topic),
4698                )
4699                .await
4700                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4701
4702                // Notify lookup services. A hook error is dropped, not
4703                // reported (parity: the reference catches it, best effort).
4704                for ls in self.lookup_services.values() {
4705                    let _ = hooked(
4706                        bound,
4707                        ls.output_no_longer_retained_in_history(
4708                            &output.txid,
4709                            output.output_index,
4710                            &output.topic,
4711                        ),
4712                    )
4713                    .await;
4714                }
4715            }
4716
4717            // Recurse into consumed outputs
4718            for consumed in &output.outputs_consumed {
4719                if let Ok(Some(mut stale_output)) = stored(
4720                    bound,
4721                    self.storage.find_output(
4722                        &consumed.txid,
4723                        consumed.output_index,
4724                        Some(&output.topic),
4725                        None,
4726                        false,
4727                    ),
4728                )
4729                .await
4730                {
4731                    // Remove the deleted output from consumedBy
4732                    stale_output.consumed_by.retain(|c| {
4733                        !(c.txid == output.txid && c.output_index == output.output_index)
4734                    });
4735
4736                    stored(
4737                        bound,
4738                        self.storage.update_consumed_by(
4739                            &consumed.txid,
4740                            consumed.output_index,
4741                            &output.topic,
4742                            &stale_output.consumed_by,
4743                        ),
4744                    )
4745                    .await
4746                    .map_err(|e| EngineError::StorageError(e.to_string()))?;
4747
4748                    // Recurse
4749                    let _ = self.delete_utxo_deep(&stale_output, bound).await;
4750                }
4751            }
4752
4753            Ok(())
4754        }) // Box::pin
4755    }
4756
4757    // ========================================================================
4758    // Eviction
4759    // ========================================================================
4760
4761    /// Evict a specific outpoint from the overlay.
4762    ///
4763    /// 1. Deletes the output from storage for the given topic.
4764    /// 2. Notifies all lookup services via `output_evicted()`.
4765    ///
4766    /// If no topic is specified, finds all outputs for the txid and evicts the
4767    /// matching outputIndex across every topic.
4768    ///
4769    /// Matches TS OverlayExpress `/admin/evictOutpoint` (lines 974-1004).
4770    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): RE-NOTIFY every lookup service
4771    /// of the outputs the engine ALREADY admitted for (`txid`, `topic`).
4772    ///
4773    /// Phase-1 dedup means a (txid, topic) once recorded in
4774    /// `applied_transactions` never re-enters through `/submit`, so a lookup
4775    /// service that missed — or mis-parsed — its admit notification could
4776    /// never be told again (the loop-2 subject trap: the pot index's admit
4777    /// hook shape-checked a wire-last hop and wrote no record while the engine
4778    /// had admitted the pot). This replays `output_admitted_by_topic` from the
4779    /// engine's OWN stored outputs (with their BEEF) and the subject-NAMED body
4780    /// exactly as `submit_with_report` sends it. It validates nothing anew
4781    /// (the topic manager judged these outputs at admit), writes none of the
4782    /// engine's tables, and is idempotent for every lookup service whose
4783    /// writes are (the pot index's `store_record` is insert-if-absent for the
4784    /// spend fields — re-admission never regresses spend state).
4785    /// `outputs == 0` means the engine never admitted this txid on this topic:
4786    /// nothing to re-notify — the caller re-SUBMITS instead.
4787    pub async fn renotify_admitted(
4788        &self,
4789        txid: &str,
4790        topic: &str,
4791    ) -> Result<RenotifyReport, EngineError> {
4792        let txid = txid.to_ascii_lowercase();
4793        let outputs = self
4794            .storage
4795            .find_outputs_for_transaction(&txid, true)
4796            .await
4797            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4798        let mut report = RenotifyReport::default();
4799        for output in outputs.iter().filter(|o| o.topic == topic) {
4800            report.outputs += 1;
4801            report.vouts.push(output.output_index);
4802            // the body every WholeTx service receives NAMES the subject (BRC-95
4803            // atomic prefix) — the same rule as the live submit path
4804            let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4805                beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4806                    .ok()
4807                    .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4808                    .unwrap_or_else(|| beef.clone())
4809            });
4810            for (name, ls) in &self.lookup_services {
4811                let payload = match ls.admission_mode() {
4812                    AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4813                        txid: txid.clone(),
4814                        output_index: output.output_index,
4815                        topic: topic.to_string(),
4816                        satoshis: output.satoshis,
4817                        locking_script: output.output_script.clone(),
4818                        off_chain_values: None,
4819                    },
4820                    AdmissionMode::WholeTx => {
4821                        let Some(atomic_beef) = subject_named.as_ref() else {
4822                            report.faults.push(format!(
4823                                "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4824                                output.output_index
4825                            ));
4826                            continue;
4827                        };
4828                        OutputAdmittedByTopic::WholeTx {
4829                            atomic_beef: atomic_beef.clone(),
4830                            output_index: output.output_index,
4831                            topic: topic.to_string(),
4832                            off_chain_values: None,
4833                        }
4834                    }
4835                };
4836                match ls.output_admitted_by_topic(&payload).await {
4837                    Ok(()) => report.notified += 1,
4838                    Err(e) => report.faults.push(format!("{name}: {e}")),
4839                }
4840            }
4841        }
4842        Ok(report)
4843    }
4844
4845    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): FORGET a PHANTOM applied row —
4846    /// (txid, topic) recorded in `applied_transactions` while the topic holds
4847    /// NO output of that txid (the engine judged the tx and admitted nothing:
4848    /// the loop-2 JOIN `f9e85aab…` under the pre-D5 subject rule). Such a row
4849    /// does nothing but block a correct re-submit through the Phase-1 dedup
4850    /// (`/admin/readmit` on beta answered `deduped: ["tm_pot_beta"]` and the
4851    /// pot stayed `known:false`). A row with ANY stored output on the topic is
4852    /// a real admission and is never touched (returns false, as does a missing
4853    /// row). The forgotten bytes are then re-VALIDATED by the topic manager
4854    /// like any first submit — nothing is admitted here.
4855    pub async fn forget_phantom_applied(
4856        &self,
4857        txid: &str,
4858        topic: &str,
4859    ) -> Result<bool, EngineError> {
4860        let txid = txid.to_ascii_lowercase();
4861        let outputs = self
4862            .storage
4863            .find_outputs_for_transaction(&txid, false)
4864            .await
4865            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4866        if outputs.iter().any(|o| o.topic == topic) {
4867            return Ok(false);
4868        }
4869        let rec = AppliedTransaction {
4870            txid: txid.clone(),
4871            topic: topic.to_string(),
4872        };
4873        let exists = self
4874            .storage
4875            .does_applied_transaction_exist(&rec)
4876            .await
4877            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4878        if !exists {
4879            return Ok(false);
4880        }
4881        self.storage
4882            .delete_applied_transaction(&rec)
4883            .await
4884            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4885        Ok(true)
4886    }
4887
4888    pub async fn evict_output(
4889        &self,
4890        txid: &str,
4891        output_index: u32,
4892        topic: Option<&str>,
4893    ) -> Result<(), EngineError> {
4894        if let Some(topic) = topic {
4895            // Delete from storage
4896            self.storage
4897                .delete_output(txid, output_index, topic)
4898                .await
4899                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4900
4901            // Notify all lookup services
4902            for ls in self.lookup_services.values() {
4903                let _ = ls.output_evicted(txid, output_index).await;
4904            }
4905        } else {
4906            // No topic specified — find all outputs for this txid and evict matching ones
4907            let outputs = self
4908                .storage
4909                .find_outputs_for_transaction(txid, false)
4910                .await
4911                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4912
4913            for output in &outputs {
4914                if output.output_index == output_index {
4915                    self.storage
4916                        .delete_output(txid, output_index, &output.topic)
4917                        .await
4918                        .map_err(|e| EngineError::StorageError(e.to_string()))?;
4919                }
4920            }
4921
4922            // Notify all lookup services once
4923            for ls in self.lookup_services.values() {
4924                let _ = ls.output_evicted(txid, output_index).await;
4925            }
4926        }
4927
4928        Ok(())
4929    }
4930
4931    // ========================================================================
4932    // GASP sync
4933    // ========================================================================
4934
4935    /// Start GASP synchronization with peers for all configured topics.
4936    ///
4937    /// For each topic in `sync_configuration`:
4938    /// - `SyncTarget::Ship`: unions local SHIP records, network discovery via
4939    ///   configured SLAP trackers, and the topic's bootstrap peers.
4940    /// - `SyncTarget::Peers(urls)` — uses the provided peer URLs directly.
4941    /// - `SyncTarget::Disabled` — skips the topic.
4942    ///
4943    /// Filters out our own `hosting_url` to avoid self-sync.
4944    ///
4945    /// Returns a `GASPSyncResult` summarizing discovered peers per topic.
4946    ///
4947    /// Discovers peers for each configured topic and runs GASP sync with each.
4948    ///
4949    /// When a `GASPRemoteFactory` is set (via `set_gasp_remote_factory`), this
4950    /// method creates `OverlayGASPStorage` + `GASPRemote` instances per
4951    /// (topic, peer) pair and runs `GASPSync::sync()` to exchange UTXOs.
4952    ///
4953    /// Without a factory, peer discovery still runs but no actual sync occurs
4954    /// (backwards-compatible with the previous discovery-only behavior).
4955    pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
4956        self.start_gasp_sync_filtered(&crate::sync_load::SyncFilter::default())
4957            .await
4958    }
4959
4960    /// Run selected topic/peer pairs; explicit peers bypass network discovery.
4961    pub async fn start_gasp_sync_filtered(
4962        &self,
4963        filter: &crate::sync_load::SyncFilter,
4964    ) -> Result<GASPSyncResult, EngineError> {
4965        let meter = crate::meter::LoadMeter::default();
4966        self.start_gasp_sync_metered(filter, &meter).await
4967    }
4968
4969    /// A caller holding the meter can read partial costs after cancellation.
4970    pub async fn start_gasp_sync_metered(
4971        &self,
4972        filter: &crate::sync_load::SyncFilter,
4973        meter: &crate::meter::LoadMeter,
4974    ) -> Result<GASPSyncResult, EngineError> {
4975        let run = self.gasp_run();
4976        meter.scope(self.run_gasp_sync(filter, &run, None)).await
4977    }
4978
4979    fn gasp_run(&self) -> crate::backfill::Run {
4980        crate::backfill::Run::new(
4981            self.gasp_ancestor_fetcher
4982                .as_ref()
4983                .or(self.ancestor_fetcher.as_ref())
4984                .cloned(),
4985        )
4986    }
4987
4988    /// Walk one selected pair, checkpointing on wall, request or progress bounds.
4989    /// The continuation never supplies a cursor to storage or to the remote.
4990    pub async fn start_gasp_backfill_metered(
4991        &self,
4992        request: &crate::backfill::SyncRequest,
4993        meter: &crate::meter::LoadMeter,
4994    ) -> Result<crate::backfill::BackfillResult, EngineError> {
4995        use crate::backfill::{
4996            BackfillResult, Continuation, Control, BACKFILL_MAX_SLICES, BACKFILL_REQUEST_MS,
4997        };
4998        request.validate().map_err(EngineError::InvalidQuery)?;
4999        let (topic, peer) = request.pair().map_err(EngineError::InvalidQuery)?;
5000        if request.until.is_none() || self.peer_sync_budget.is_none() || self.graph_budget.is_none()
5001        {
5002            return Err(EngineError::InvalidQuery(
5003                "backfill requires until: tip and peer/graph bounds".into(),
5004            ));
5005        }
5006        if self.gasp_remote_factory.is_none()
5007            || !matches!(
5008                self.config.sync_configuration.get(topic),
5009                Some(SyncTarget::Ship | SyncTarget::Peers(_))
5010            )
5011        {
5012            return Err(EngineError::InvalidQuery(
5013                "backfill topic is not enabled for sync".into(),
5014            ));
5015        }
5016        meter
5017            .scope(async {
5018                let initial = self.storage.get_last_interaction(peer, topic).await?;
5019                if let Some(token) = &request.continuation {
5020                    let token = Continuation::decode(token).map_err(EngineError::InvalidQuery)?;
5021                    if token.cursor > initial {
5022                        return Err(EngineError::InvalidQuery(
5023                            "continuation is ahead of the durable cursor".into(),
5024                        ));
5025                    }
5026                }
5027                let filter = request.filter();
5028                let run = self.gasp_run();
5029                let control = Control {
5030                    meter: meter.clone(),
5031                    budget_ms: request.budget_ms.unwrap_or(0).min(BACKFILL_REQUEST_MS),
5032                };
5033                let mut result = BackfillResult {
5034                    sync: GASPSyncResult {
5035                        filter: filter.clone(),
5036                        ..GASPSyncResult::default()
5037                    },
5038                    slices: 0,
5039                    reached_tip: false,
5040                    stop_reason: "slice_allowance".into(),
5041                    continuation: None,
5042                };
5043                let mut previous_cursor = initial;
5044                for _ in 0..BACKFILL_MAX_SLICES {
5045                    if let Some(reason) = control.stop_reason() {
5046                        result.stop_reason = reason.into();
5047                        break;
5048                    }
5049                    let slice = self.run_gasp_sync(&filter, &run, Some(&control)).await?;
5050                    let topic_result = slice.topics_synced.get(topic).ok_or_else(|| {
5051                        EngineError::InvalidQuery("backfill selection matched no topic".into())
5052                    })?;
5053                    if !topic_result.peers.iter().any(|p| p == peer) {
5054                        return Err(EngineError::InvalidQuery(
5055                            "backfill selection matched no peer".into(),
5056                        ));
5057                    }
5058                    let summary = topic_result.peer_results.get(peer).cloned();
5059                    let waiting = !topic_result.peers_skipped.is_empty();
5060                    result.sync.append_slice(slice);
5061                    if let Some(summary) = summary {
5062                        result.slices += summary.slices;
5063                        if summary.reached_tip {
5064                            result.reached_tip = true;
5065                            result.stop_reason = "tip".into();
5066                            break;
5067                        }
5068                        if let Some(reason) = control.stop_reason() {
5069                            result.stop_reason = reason.into();
5070                            break;
5071                        }
5072                        if summary.cursor <= previous_cursor {
5073                            result.stop_reason = "no_progress".into();
5074                            break;
5075                        }
5076                        previous_cursor = summary.cursor;
5077                    } else {
5078                        result.stop_reason =
5079                            if waiting { "peer_wait" } else { "no_progress" }.into();
5080                        break;
5081                    }
5082                }
5083                let cursor = self.storage.get_last_interaction(peer, topic).await?;
5084                if !result.reached_tip {
5085                    result.continuation = Some(Continuation::new(topic, peer, cursor).encode());
5086                }
5087                result.sync.cost = meter.snapshot();
5088                Ok(result)
5089            })
5090            .await
5091    }
5092
5093    async fn run_gasp_sync(
5094        &self,
5095        filter: &crate::sync_load::SyncFilter,
5096        run: &crate::backfill::Run,
5097        control: Option<&crate::backfill::Control>,
5098    ) -> Result<GASPSyncResult, EngineError> {
5099        use crate::gasp::{GASPSync, ENGINE_GASP_SYNC_PAGE_LIMIT};
5100        use crate::gasp_overlay::OverlayGASPStorage;
5101
5102        if self.config.sync_configuration.is_empty() {
5103            info!("[GASP SYNC] No sync configuration — nothing to sync");
5104            return Ok(GASPSyncResult {
5105                cost: crate::meter::SyncCost::default(),
5106                topics_synced: HashMap::new(),
5107                filter: filter.clone(),
5108                topics_filtered: Vec::new(),
5109            });
5110        }
5111
5112        let sync_fetcher = &run.fetcher;
5113        let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
5114        // Resolve SLAP providers once per invocation, sharing their host cap
5115        // across all SHIP topics. Every topic still queries each lookup host.
5116        let mut ship_lookup_hosts = None;
5117        let mut topic_order = sync_order(&self.config.sync_configuration);
5118        // Explicit bootstrap-backed SHIP topics run ahead of large tracker
5119        // imports. Stable sorting preserves the existing order elsewhere.
5120        topic_order.sort_by_key(|(topic, target)| {
5121            !(matches!(target, SyncTarget::Ship)
5122                && self
5123                    .ship_bootstrap_peers
5124                    .get(topic.as_str())
5125                    .is_some_and(|peers| !peers.is_empty()))
5126        });
5127
5128        let mut topics_filtered = Vec::new();
5129        for (topic, target) in topic_order {
5130            if filter
5131                .topics
5132                .as_ref()
5133                .is_some_and(|names| !names.contains(topic))
5134            {
5135                topics_filtered.push(topic.clone());
5136                continue;
5137            }
5138            let cost_before = crate::meter::snapshot();
5139            // A CONFIGURED topic's peers are reserved part of a storage's
5140            // deferred-graph ceiling (bsv-low #555, the delta-2 fold's D2-M2).
5141            let configured_peers = matches!(target, SyncTarget::Peers(_));
5142            let (peers, sync_type) = match target {
5143                SyncTarget::Disabled => {
5144                    info!("[GASP SYNC] Topic {topic} is disabled — skipping");
5145                    continue;
5146                }
5147                SyncTarget::Ship => {
5148                    info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
5149                    let peers = if let Some(peers) = &filter.peers {
5150                        self.ship_peers_excluding_self(peers.clone())
5151                    } else {
5152                        self.discover_ship_peers(topic, &mut ship_lookup_hosts)
5153                            .await
5154                    };
5155                    info!(
5156                        "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
5157                        peers.len()
5158                    );
5159                    (peers, "ship".to_string())
5160                }
5161                SyncTarget::Peers(peer_urls) => {
5162                    info!(
5163                        "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
5164                        peer_urls.len()
5165                    );
5166                    let peers: Vec<String> = peer_urls
5167                        .iter()
5168                        .filter(|url| {
5169                            if let Some(ref our_url) = self.config.hosting_url {
5170                                url.trim_end_matches('/') != our_url.trim_end_matches('/')
5171                            } else {
5172                                true
5173                            }
5174                        })
5175                        .cloned()
5176                        .collect();
5177
5178                    info!(
5179                        "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
5180                        peers.len()
5181                    );
5182                    (peers, "peers".to_string())
5183                }
5184            };
5185
5186            let (peers, peers_filtered): (Vec<_>, Vec<_>) = peers.into_iter().partition(|peer| {
5187                filter
5188                    .peers
5189                    .as_ref()
5190                    .is_none_or(|names| names.contains(peer))
5191            });
5192            let mut peers_skipped = Vec::new();
5193            let advertisements = self.peer_advertisement_versions(topic).await;
5194            let mut errors = Vec::new();
5195            let mut pruned_inputs: u64 = 0;
5196            let mut discarded_graphs: u64 = 0;
5197            let mut expired_refused: u64 = 0;
5198            let mut roots_proven = 0;
5199            let mut roots_unproven = 0;
5200            let mut cursor_held_by = Vec::new();
5201            let expired = run.expired(topic);
5202            let mut peer_results = HashMap::new();
5203            let mut anchor_walk_held_graphs: u64 = 0;
5204            let mut finalized_graphs: u64 = 0;
5205            let mut deadline_dropped_graphs: u64 = 0;
5206            let mut cursor_moves: Vec<CursorMove> = Vec::new();
5207            let mut deferred_graphs: u64 = 0;
5208            let mut resumed_graphs: u64 = 0;
5209            let mut converged_graphs: u64 = 0;
5210            let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
5211            let mut stalled_graphs: u64 = 0;
5212            let mut held_back_graphs: u64 = 0;
5213            let mut dead_graphs = 0;
5214            let mut expired_swept = 0;
5215            let mut memo_folded = 0;
5216            let mut unanchorable_graphs: u64 = 0;
5217            let mut unanchorable_cleared: u64 = 0;
5218            let mut absent_struck: u64 = 0;
5219            let mut route_faults: Vec<crate::route_fault::RouteFaultSeen> = Vec::new();
5220            let mut strikes_refused: u64 = 0;
5221            let mut backed_off_graphs: u64 = 0;
5222            let mut waiting_graphs: u64 = 0;
5223            let mut idle_counted: u64 = 0;
5224            let mut root_bounds = Vec::new();
5225            let mut faulted_roots: Vec<crate::gasp::FaultedRoot> = Vec::new();
5226
5227            // If we have a remote factory, actually run GASP sync
5228            if let Some(ref factory) = self.gasp_remote_factory {
5229                for peer_url in &peers {
5230                    // bsv-low#302 quarantine gate: a peer at
5231                    // PEER_QUARANTINE_THRESHOLD consecutive failures is
5232                    // SKIPPED (no attempt recorded — its last-attempt age
5233                    // keeps growing) until the re-probe window opens. A
5234                    // health-read FAULT treats the peer as healthy: broken
5235                    // bookkeeping must never silence a live peer.
5236                    //
5237                    // The peer's health (its quarantine, its yieldless
5238                    // streak) is keyed on its NORMALIZED ORIGIN (bsv-low
5239                    // #555, the delta-2 fold's D2-M2): keyed on the URL, one
5240                    // server was a fresh peer for every spelling it
5241                    // advertised.
5242                    let health_key = crate::gasp::peer_origin(peer_url);
5243                    let health = self
5244                        .storage
5245                        .get_peer_sync_health(&health_key, topic)
5246                        .await
5247                        .unwrap_or_default();
5248                    if crate::gasp::peer_sync_quarantined(&health) {
5249                        peers_skipped.push(crate::sync_load::SkippedPeer {
5250                            peer: peer_url.clone(),
5251                            reason: "quarantine".into(),
5252                            retry_after_secs: crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5253                                .saturating_sub(health.secs_since_last_attempt.unwrap_or(0)),
5254                        });
5255                        warn!(
5256                            "[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)",
5257                            health.consecutive_failures,
5258                            health.secs_since_last_attempt,
5259                            crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5260                        );
5261                        continue;
5262                    }
5263
5264                    let cadence = self
5265                        .storage
5266                        .get_peer_cadence(&health_key, topic)
5267                        .await
5268                        .unwrap_or_default();
5269                    let advertisement =
5270                        advertisements.get(&health_key).cloned().unwrap_or_default();
5271                    let reset_cadence = filter.explicit() || cadence.advertisement != advertisement;
5272                    if !reset_cadence && cadence.retry_after_secs > 0 {
5273                        peers_skipped.push(crate::sync_load::SkippedPeer {
5274                            peer: peer_url.clone(),
5275                            reason: "no_yield".into(),
5276                            retry_after_secs: cadence.retry_after_secs,
5277                        });
5278                        continue;
5279                    }
5280
5281                    let peer_cost_before = crate::meter::snapshot();
5282                    info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
5283
5284                    // Get last interaction score for this (peer, topic) pair
5285                    let last_interaction = self
5286                        .storage
5287                        .get_last_interaction(peer_url, topic)
5288                        .await
5289                        .unwrap_or(0);
5290
5291                    // Create shared sink for finalized graphs
5292                    let sink = crate::gasp_overlay::new_finalized_graph_sink();
5293
5294                    // OPT-IN ancestor hydration: only when a fetcher is
5295                    // configured do we enable chain-backed ancestor fallback +
5296                    // strict-BEEF finalize. Default (None) = byte-identical to
5297                    // today (tolerant BEEF, peer errors abandon the graph;
5298                    // the one exception is the D8 prune of a manager-named
5299                    // input the peer answers it does not hold, which exists
5300                    // only WITHOUT a fetcher, see
5301                    // `GASPSync::process_incoming_node`).
5302                    let hydration_on = sync_fetcher.is_some();
5303
5304                    // Create storage adapter and remote
5305                    let mut gasp_storage =
5306                        OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5307                            .with_peer(peer_url.as_str())
5308                            .with_configured_peer(configured_peers)
5309                            .with_strict_beef(hydration_on)
5310                            .with_script_verification(self.verify_scripts)
5311                            .with_walk_budget(self.walk_budget);
5312                    if let Some(manager) = self.managers.get(topic) {
5313                        gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5314                    }
5315                    // bsv-low #551: the anchor check verifies each graph's
5316                    // root against THIS engine's tracker, with this engine's
5317                    // script switch, before finalize. No tracker is the
5318                    // reference's 'scripts only'.
5319                    if let Some(tracker) = self.chain_tracker.as_deref() {
5320                        gasp_storage = gasp_storage.with_chain_tracker(tracker);
5321                    }
5322                    let gasp_remote = Box::new(crate::load_remote::MeteredRemote(
5323                        factory.create_remote(peer_url, topic),
5324                    ));
5325
5326                    let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5327                    // Graphs of this peer handed to submit (bsv-low #552).
5328                    let peer_finalized = std::cell::Cell::new(0u64);
5329                    let mut sync = GASPSync::new(
5330                        Box::new(gasp_storage),
5331                        gasp_remote,
5332                        last_interaction,
5333                        &log_prefix,
5334                        true, // unidirectional — overlay GASP is pull-only (submitNode throws); matches TS Engine.startGASPSync
5335                    )
5336                    .with_ancestor_fetcher(sync_fetcher.clone())
5337                    .with_expired_outpoints(std::rc::Rc::clone(&expired));
5338                    if let Some(control) = control {
5339                        let control = control.clone();
5340                        sync = sync.with_page_slice(std::rc::Rc::new(move || {
5341                            control.stop_reason().is_some()
5342                        }));
5343                    }
5344                    // bsv-low #555: a graph past its own budget is deferred,
5345                    // not dropped with the sync.
5346                    if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5347                        let (sleep, budget_ms) =
5348                            (sleep.clone(), (*budget_ms).min(crate::meter::ROOT_MAX_MS));
5349                        let half_sleep = sleep.clone();
5350                        let share_sleep = sleep.clone();
5351                        sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5352                            max_calls: *max_calls,
5353                            max_bytes_fetched: self.graph_budget_limbs.0,
5354                            max_nodes: self.graph_budget_limbs.1,
5355                            deadline: Box::new(move || sleep(budget_ms)),
5356                            half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5357                        });
5358                        // Engine 0.1.11: the resumes take a named share of
5359                        // the per-peer slice, the rest is the page's.
5360                        if let Some((_, peer_ms)) = &self.peer_sync_budget {
5361                            let share_ms = crate::gasp::resume_share_ms(*peer_ms, budget_ms);
5362                            sync = sync.with_resume_share(Box::new(move || share_sleep(share_ms)));
5363                        }
5364                    }
5365                    // bsv-low #552: under a budget each graph is submitted as
5366                    // it finalizes, so the deadline cannot take it back. With
5367                    // no budget nothing can drop the sync, and the graphs are
5368                    // submitted after it as they always were.
5369                    if self.peer_sync_budget.is_some() {
5370                        sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5371                            engine: self,
5372                            sink: sink.clone(),
5373                            peer_url,
5374                            topic,
5375                            submitted: &peer_finalized,
5376                        }));
5377                    }
5378
5379                    // bsv-low#302: bound THIS peer's sync to the configured
5380                    // budget. `None` from the race = the budget won and the
5381                    // sync future was dropped mid-flight. What it finalized
5382                    // before that is already submitted (the hook above); the
5383                    // cursor then advances to `completed_cursor` only, see
5384                    // the deadline arm below. The race is GUARDED: it never
5385                    // drops the sync inside one transaction's finalize
5386                    // submit (the lens fold's HIGH-1), it waits for that
5387                    // transaction and the hook stops at the boundary.
5388                    let sync_outcome = match &self.peer_sync_budget {
5389                        Some((sleep, budget_ms)) => {
5390                            let budget_ms =
5391                                control.map_or(*budget_ms, |c| (*budget_ms).min(c.remaining_ms()));
5392                            let mut deadline = sleep(budget_ms);
5393                            let deadline = std::future::poll_fn(|cx| {
5394                                if control.is_some_and(|c| c.stop_reason().is_some())
5395                                    || std::future::Future::poll(deadline.as_mut(), cx).is_ready()
5396                                {
5397                                    std::task::Poll::Ready(())
5398                                } else {
5399                                    std::task::Poll::Pending
5400                                }
5401                            });
5402                            crate::gasp::race_or_deadline_guarded(
5403                                sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5404                                deadline,
5405                                &self.finalize_gate,
5406                            )
5407                            .await
5408                        }
5409                        None => Some(sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)).await),
5410                    };
5411
5412                    let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5413                    // Engine 0.2.0 (#57): the platform answered in the peer's
5414                    // place, so this attempt says nothing about the peer.
5415                    let route_faulted = matches!(
5416                        sync_outcome,
5417                        Some(Err(crate::gasp::GASPError::RouteFault { .. }))
5418                    );
5419                    // D8 decoy rule: branches pruned in this peer's walk. Not
5420                    // an error and not a failed UTXO, so it is counted apart
5421                    // from `errors` and never touches the outcome or cursor.
5422                    // Summed over peers: one decoy seen through two peers
5423                    // counts twice.
5424                    pruned_inputs += sync.pruned_inputs();
5425                    expired_refused += sync.expired_refused();
5426                    roots_proven += sync.roots_proven();
5427                    roots_unproven += sync.roots_unproven();
5428                    // bsv-low #551: graphs the anchor check refused. Counted
5429                    // apart from `errors` for the same reason as a prune: a
5430                    // refused graph is not a failed sync (the reference
5431                    // discards it and carries on).
5432                    discarded_graphs += sync.discarded_graphs();
5433                    anchor_walk_held_graphs += sync.anchor_walk_held();
5434                    // Whether the sync ran (to its end, or to the deadline)
5435                    // and whether it moved the cursor (bsv-low #555, the
5436                    // delta fold's D-M2).
5437                    let ran = !matches!(sync_outcome, Some(Err(_)));
5438                    let mut advanced_cursor = false;
5439                    match sync_outcome {
5440                        None => {
5441                            let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5442                            let msg = format!(
5443                                "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5444                            );
5445                            warn!("[GASP SYNC] {msg}");
5446                            errors.push(msg);
5447
5448                            // bsv-low #552: the work COMPLETED before the
5449                            // deadline is kept. Its graphs are already
5450                            // submitted (the hook); the cursor moves past the
5451                            // UTXOs that were completed and stops below the
5452                            // one in flight, whose graph is lost whole and
5453                            // walked again next tick. A graph the deadline
5454                            // met while it was being SUBMITTED is not "in
5455                            // flight" here: it left a prefix of whole
5456                            // transactions, is counted finalized, and its
5457                            // UTXO is below the cursor too.
5458                            let in_flight = u64::from(sync.graph_in_flight());
5459                            deadline_dropped_graphs += in_flight;
5460                            // bsv-low #555: the walk in hand is saved, so the
5461                            // next tick resumes it instead of walking it again.
5462                            sync.defer_in_flight().await;
5463                            let completed = sync.completed_cursor();
5464                            if completed > last_interaction {
5465                                match self
5466                                    .storage
5467                                    .update_last_interaction(peer_url, topic, completed)
5468                                    .await
5469                                {
5470                                    Ok(()) => cursor_moves.push(CursorMove {
5471                                        peer: peer_url.clone(),
5472                                        from: last_interaction,
5473                                        to: completed,
5474                                    }),
5475                                    Err(e) => warn!(
5476                                        "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5477                                    ),
5478                                }
5479                            }
5480                            // A tick that finalized a graph or moved the
5481                            // cursor reached a live peer: not a failed
5482                            // attempt for the quarantine count. So did one
5483                            // whose walk PROGRESSED (bsv-low #555: it
5484                            // appended a node, or completed its graph). A
5485                            // deferral that fetched nothing is no progress
5486                            // (the lens fold's H1: a hung peer deferred with
5487                            // 0 nodes on every tick and was never
5488                            // quarantined).
5489                            //
5490                            // Engine 0.1.8: the deadline drop itself is not a
5491                            // failure of the peer. The per-peer deadline is
5492                            // OUR slice of the tick, set by our cadence and
5493                            // the Worker's limits; a peer that served every
5494                            // page and node we asked for until it fell due
5495                            // did not fail. What the slice did is the test:
5496                            // a sync that refused an expired root, retained
5497                            // a graph or resumed one with progress did work
5498                            // too (`GASPSync::did_work`).
5499                            advanced_cursor = completed > last_interaction;
5500                            outcome_success = peer_finalized.get() > 0
5501                                || (completed > last_interaction && sync.cursor_yielded())
5502                                || sync.deferral_stats().progressed > 0
5503                                || sync.did_work();
5504                            warn!(
5505                                "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5506                                peer_finalized.get(),
5507                                completed.max(last_interaction)
5508                            );
5509                        }
5510                        Some(Ok(())) => {
5511                            // Submit finalized graphs to the Engine (under a
5512                            // budget the hook already did, graph by graph,
5513                            // and the sink is empty here).
5514                            let landed = self
5515                                .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5516                                .await;
5517                            // bsv-low #555, the lens fold's H1: a sync that
5518                            // ran to its end with walks the per-graph budget
5519                            // cut having FETCHED NOTHING, and nothing else
5520                            // got done (no walk progressed, no graph
5521                            // finalized, the cursor did not move), reached
5522                            // a peer that does not answer: a FAILED attempt.
5523                            // Before #555 such a peer held the sync to the
5524                            // per-peer deadline, a failure; the per-graph
5525                            // budget had made it an `Ok` sync. A sync whose
5526                            // UTXOs failed in any other way is unchanged.
5527                            let stats = sync.deferral_stats();
5528                            if stats.stalled > 0
5529                                && stats.progressed == 0
5530                                && peer_finalized.get() == 0
5531                                && !sync.did_work()
5532                                && !(landed
5533                                    && sync.last_interaction > last_interaction
5534                                    && sync.cursor_yielded())
5535                            {
5536                                outcome_success = false;
5537                                let msg = format!(
5538                                    "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5539                                    stats.stalled
5540                                );
5541                                warn!("[GASP SYNC] {msg}");
5542                                errors.push(msg);
5543                            }
5544
5545                            // Advance the persisted cursor when it moved forward — i.e.
5546                            // the peer reported UTXOs at a higher score than our last
5547                            // `since` (the in-memory cursor only grows on SEEN scores,
5548                            // so `>` already excludes the empty-bootstrap case where no
5549                            // UTXOs were seen). Mirrors TS `if (gasp.lastInteraction >
5550                            // lastInteraction)` — NOT gated on a new submission: a sync
5551                            // that re-sees already-known UTXOs must still advance, else
5552                            // every cron re-scans the same range forever (the cursor
5553                            // stayed pinned at 0 in prod).
5554                            //
5555                            // NOT advanced when a finalize submit above did
5556                            // not land (the lens fold's MEDIUM-1): the sink
5557                            // does not say which UTXO that graph was, so the
5558                            // whole cursor stays and the next sync is served
5559                            // the range again (what did land is known and
5560                            // skipped). Under a budget the hook fails that
5561                            // one UTXO instead and the gap guard has already
5562                            // capped `sync.last_interaction` below it.
5563                            advanced_cursor = landed && sync.last_interaction > last_interaction;
5564                            if !landed {
5565                                warn!(
5566                                    "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5567                                );
5568                            } else if sync.last_interaction > last_interaction {
5569                                match self
5570                                    .storage
5571                                    .update_last_interaction(peer_url, topic, sync.last_interaction)
5572                                    .await
5573                                {
5574                                    Ok(()) => cursor_moves.push(CursorMove {
5575                                        peer: peer_url.clone(),
5576                                        from: last_interaction,
5577                                        to: sync.last_interaction,
5578                                    }),
5579                                    Err(e) => warn!(
5580                                        "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5581                                    ),
5582                                }
5583                            }
5584                            info!(
5585                                "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5586                                sync.last_interaction
5587                            );
5588                        }
5589                        Some(Err(e)) => {
5590                            let msg = format!("{peer_url}: {e}");
5591                            warn!("[GASP SYNC] Sync failed: {msg}");
5592                            errors.push(msg);
5593                            if let crate::gasp::GASPError::RouteFault { class, .. } = e {
5594                                route_faults.push(crate::route_fault::RouteFaultSeen {
5595                                    peer: peer_url.clone(),
5596                                    class,
5597                                });
5598                            }
5599                        }
5600                    }
5601                    // Engine 0.1.8: work is not a failure. A sync that
5602                    // finalized a graph, moved the cursor past what it
5603                    // finished, refused an expired root not refused before,
5604                    // retained a graph or resumed one with progress is never
5605                    // a failed attempt (a peer is quarantined for being
5606                    // dead, never for our cadence), even if it then faulted.
5607                    //
5608                    // The YIELD that ends the yieldless streak (and alone
5609                    // lifts a quarantine, D4-L2) is the narrower set: a
5610                    // finalized graph, a moved cursor, a refusal. A held or
5611                    // resumed walk is the peer's word until its anchor is
5612                    // checked: counted as a yield, a peer that lists one
5613                    // fabricated root a tick, serves it and hangs on its
5614                    // input would never be quarantined (bsv-low #555's D-M2).
5615                    // Such syncs stay inside the yieldless allowance (12
5616                    // syncs and 3 h), which an honest deep graph converges in.
5617                    let yielded = peer_finalized.get() > 0
5618                        || (advanced_cursor && sync.cursor_yielded())
5619                        || sync.expired_refused() > 0;
5620                    if yielded || sync.did_work() {
5621                        outcome_success = true;
5622                    }
5623                    // Engine 0.2.0 (#57): a route fault with no work done
5624                    // before it touches none of the peer's health rows: not
5625                    // its failures (the quarantine), not its yieldless
5626                    // streak, not its quiet cadence. The quarantine judges
5627                    // the peer, never the route. A sync the peer served work
5628                    // in before the route faulted is the peer's answer and
5629                    // is recorded as one.
5630                    let route_excused = route_faulted && !outcome_success;
5631                    if route_excused {
5632                        info!(
5633                            "[GASP SYNC] {peer_url} for {topic}: {} before the peer was reached; nothing counted against the peer (#57)",
5634                            route_faults
5635                                .last()
5636                                .map_or(String::new(), crate::route_fault::RouteFaultSeen::label)
5637                        );
5638                    }
5639                    // bsv-low #555, the delta fold's D-M2: "progress" (a node
5640                    // appended) is the peer's word. A sync the peer served
5641                    // work that finalized no graph and moved no cursor is
5642                    // YIELDLESS; past PEER_YIELDLESS_SYNCS_ALLOWED of them in
5643                    // a row each is a FAILED attempt, so a peer that serves
5644                    // one fresh root a tick and hangs on its input is
5645                    // quarantined. A quiet sync (nothing served) leaves the
5646                    // count; a fault of the count is fail-safe (no failure).
5647                    // Only with a per-graph budget: without one nothing is
5648                    // deferred and #302's rule is unchanged.
5649                    if ran
5650                        && self.graph_budget.is_some()
5651                        && (yielded || sync.graphs_attempted() > 0)
5652                    {
5653                        match self
5654                            .storage
5655                            .record_peer_sync_yield(&health_key, topic, yielded)
5656                            .await
5657                        {
5658                            Ok(streak)
5659                                if !yielded
5660                                    && crate::gasp::yieldless_sync_failed(&streak)
5661                                    && outcome_success =>
5662                            {
5663                                outcome_success = false;
5664                                let msg = format!(
5665                                    "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5666                                    streak.yieldless_syncs,
5667                                    streak.secs_since_first.unwrap_or(0),
5668                                    crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5669                                    crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5670                                );
5671                                warn!("[GASP SYNC] {msg}");
5672                                errors.push(msg);
5673                            }
5674                            Ok(_) => {}
5675                            Err(e) => warn!(
5676                                "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5677                            ),
5678                        }
5679                    }
5680                    // Quiet success controls polling cadence, never peer health. Faults
5681                    // retain #302's attempt cadence; retained work keeps its resume slice.
5682                    let quiet = outcome_success
5683                        && !yielded
5684                        && !sync.did_work()
5685                        && sync.faulted_roots().is_empty()
5686                        && sync.deferral_stats().stalled == 0;
5687                    let quiet_syncs = if quiet && !filter.explicit() {
5688                        if reset_cadence {
5689                            1
5690                        } else {
5691                            cadence.quiet_syncs.saturating_add(1)
5692                        }
5693                    } else {
5694                        0
5695                    };
5696                    let next = crate::sync_load::PeerCadence {
5697                        quiet_syncs,
5698                        retry_after_secs: crate::sync_load::quiet_retry_secs(quiet_syncs),
5699                        advertisement,
5700                    };
5701                    if !route_excused {
5702                        if let Err(e) = self
5703                            .storage
5704                            .set_peer_cadence(&health_key, topic, &next)
5705                            .await
5706                        {
5707                            warn!(
5708                                "[GASP SYNC] failed to record cadence for {peer_url}/{topic}: {e}"
5709                            );
5710                        }
5711                    }
5712                    if !advanced_cursor {
5713                        if let Some(outpoint) = sync.cursor_held_by() {
5714                            cursor_held_by.push(outpoint);
5715                        }
5716                    }
5717                    finalized_graphs += peer_finalized.get();
5718                    let deferral = sync.deferral_stats();
5719                    root_bounds.extend(deferral.root_bounds.iter().cloned().map(|mut bound| {
5720                        bound.peer.clone_from(peer_url);
5721                        bound
5722                    }));
5723                    deferred_graphs += deferral.deferred;
5724                    resumed_graphs += deferral.resumed;
5725                    converged_graphs += deferral.converged;
5726                    stalled_graphs += deferral.stalled;
5727                    held_back_graphs += deferral.held_back;
5728                    backed_off_graphs += deferral.backed_off;
5729                    waiting_graphs += deferral.waiting;
5730                    idle_counted += deferral.idle_counted;
5731                    dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5732                        peer: peer_url.clone(),
5733                        outpoint: d.outpoint,
5734                        reason: d.reason.as_str().to_string(),
5735                    }));
5736                    // Engine 0.1.9: the roots dropped, each with why.
5737                    dead_graphs += sync.dead_recorded();
5738                    expired_swept += sync.expired_swept();
5739                    memo_folded += sync.memo_folded();
5740                    unanchorable_graphs += sync.unanchorable_recorded();
5741                    unanchorable_cleared += sync.unanchorable_cleared();
5742                    absent_struck += sync.absent_struck();
5743                    // Engine 0.1.10: a strike not written is a fact.
5744                    strikes_refused += sync.strikes_refused();
5745                    faulted_roots.extend(sync.faulted_roots().into_iter().map(|mut f| {
5746                        f.peer.clone_from(peer_url);
5747                        f
5748                    }));
5749
5750                    // bsv-low#302: record the attempt outcome (success resets
5751                    // the consecutive-failure count; timeout/error increments
5752                    // it). Best-effort — a bookkeeping fault must never fail
5753                    // the sync pass itself.
5754                    //
5755                    // bsv-low #555, the delta-4 fold's D4-L2: with a
5756                    // per-graph budget, a peer that was at the quarantine
5757                    // threshold when this sync began is re-admitted only by
5758                    // a YIELD (a graph finalized, the cursor moved). A sync
5759                    // that did not fail and yielded nothing (an empty
5760                    // listing at its probe, a walk that only progressed) is
5761                    // NOT RECORDED: the failures stay, and so does the
5762                    // last-attempt stamp, so the peer is attended again on
5763                    // the next tick and its next failure re-arms the
5764                    // quarantine at once. Recorded as a success, a hostile
5765                    // peer's empty listing at each probe reset its failures
5766                    // and (the decay being for a peer that is not failed)
5767                    // its yieldless streak: 100 of 200 ticks. A health read
5768                    // that faulted read 0 failures: the old rule, fail-safe.
5769                    if route_excused {
5770                        // Nothing recorded: see `route_excused` above.
5771                    } else if outcome_success
5772                        && !yielded
5773                        && self.graph_budget.is_some()
5774                        && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5775                    {
5776                        info!(
5777                            "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5778                            health.consecutive_failures
5779                        );
5780                    } else if let Err(e) = self
5781                        .storage
5782                        .record_peer_sync_outcome(&health_key, topic, outcome_success)
5783                        .await
5784                    {
5785                        warn!(
5786                            "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5787                        );
5788                    }
5789                    let cursor = self.storage.get_last_interaction(peer_url, topic).await?;
5790                    let cost = crate::meter::snapshot().since(peer_cost_before);
5791                    peer_results.insert(
5792                        peer_url.clone(),
5793                        crate::backfill::PeerSyncResult {
5794                            slices: u64::from(sync.page_advanced() || cost.outputs_walked > 0),
5795                            pages: sync.pages(),
5796                            reached_tip: sync.reached_tip() && cursor >= sync.completed_cursor(),
5797                            cursor,
5798                            cost,
5799                        },
5800                    );
5801                }
5802            }
5803
5804            topics_synced.insert(
5805                topic.clone(),
5806                TopicSyncResult {
5807                    cost: crate::meter::snapshot().since(cost_before),
5808                    root_bounds,
5809                    peers_skipped,
5810                    peers_filtered,
5811                    roots_proven,
5812                    roots_unproven,
5813                    cursor_held_by,
5814                    expired_refused,
5815                    peers,
5816                    sync_type,
5817                    errors,
5818                    pruned_inputs,
5819                    discarded_graphs,
5820                    anchor_walk_held_graphs,
5821                    finalized_graphs,
5822                    deadline_dropped_graphs,
5823                    cursor_moves,
5824                    deferred_graphs,
5825                    resumed_graphs,
5826                    converged_graphs,
5827                    dropped_graphs,
5828                    stalled_graphs,
5829                    held_back_graphs,
5830                    backed_off_graphs,
5831                    waiting_graphs,
5832                    idle_counted,
5833                    peer_results,
5834                    dead_graphs,
5835                    expired_swept,
5836                    memo_folded,
5837                    unanchorable_graphs,
5838                    unanchorable_cleared,
5839                    absent_struck,
5840                    route_faults,
5841                    strikes_refused,
5842                    faulted_roots,
5843                },
5844            );
5845        }
5846
5847        info!(
5848            "[GASP SYNC] Peer discovery complete for {} topic(s)",
5849            topics_synced.len()
5850        );
5851
5852        let expired: u64 = topics_synced.values().map(|r| r.expired_refused).sum();
5853        if expired > 0 {
5854            info!("[GASP SYNC] gaspExpiredRefused={expired}");
5855        }
5856        let unanchorable: u64 = topics_synced.values().map(|r| r.unanchorable_graphs).sum();
5857        if unanchorable > 0 {
5858            info!("[GASP SYNC] gaspUnanchorable={unanchorable}");
5859        }
5860        let absent: u64 = topics_synced.values().map(|r| r.absent_struck).sum();
5861        if absent > 0 {
5862            info!("[GASP SYNC] gaspAbsentStruck={absent}");
5863        }
5864        let routes: usize = topics_synced.values().map(|r| r.route_faults.len()).sum();
5865        if routes > 0 {
5866            info!("[GASP SYNC] gaspRouteFaults={routes}");
5867        }
5868        let refused: u64 = topics_synced.values().map(|r| r.strikes_refused).sum();
5869        if refused > 0 {
5870            info!("[GASP SYNC] gaspStrikesRefused={refused}");
5871        }
5872        Ok(GASPSyncResult {
5873            cost: crate::meter::snapshot(),
5874            topics_synced,
5875            filter: filter.clone(),
5876            topics_filtered,
5877        })
5878    }
5879
5880    /// Drain `sink` and submit every finalized graph in it, ancestors first
5881    /// (`historical-tx-no-spv`: the anchor check already verified them).
5882    /// Each graph is added to `submitted` as its first submit starts. Returns
5883    /// whether EVERY transaction of every graph landed.
5884    ///
5885    /// Each graph here passed the anchor check: its root verified and the
5886    /// replay admitted it, so these submits rest on a checked premise and are
5887    /// not expected to fail. If one does not LAND, the REST OF THAT GRAPH is
5888    /// not submitted: the reference's `finalizeGraph` awaits each submit in
5889    /// order and its throw ends the loop (the BEEFs already submitted stay,
5890    /// it has no rollback either). A later BEEF would otherwise be judged
5891    /// without the coin its ancestor failed to leave, admit nothing and be
5892    /// recorded as applied, a dupe for good. "Landed" is read from the
5893    /// durability report, not from `Ok`: `submit` answers `Ok` when a storage
5894    /// write faulted and when the topic manager failed, and in both it
5895    /// records no applied row. A transaction landed when its topic is applied
5896    /// or was already applied (a dupe). Other graphs carry on, and the caller
5897    /// keeps the cursor from passing a graph that did not land.
5898    ///
5899    /// Each transaction's submit is one write section of the engine's gate
5900    /// ([`Engine::finalize_submit_gate`]) and the gate is asked between two:
5901    /// a deadline that is due stops the sequence there (this future is
5902    /// dropped at that await, between two whole transactions).
5903    async fn submit_finalized_graphs(
5904        &self,
5905        sink: &crate::gasp_overlay::FinalizedGraphSink,
5906        peer_url: &str,
5907        topic: &str,
5908        submitted: &std::cell::Cell<u64>,
5909    ) -> bool {
5910        let finalized: Vec<_> = sink
5911            .lock()
5912            .unwrap_or_else(std::sync::PoisonError::into_inner)
5913            .drain(..)
5914            .collect();
5915
5916        let mut all_landed = true;
5917        for graph in &finalized {
5918            for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5919                self.finalize_gate.stop_if_due().await;
5920                if position == 0 {
5921                    submitted.set(submitted.get() + 1);
5922                }
5923                let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5924                // No deadline drops a write section, and the section does
5925                // not drop ITSELF either (bsv-low #559, the delta lens's
5926                // DELTA-3 and the lens fold of 2026-10-07, F2,
5927                // `set_finalize_submit_budget`): the submit always runs to
5928                // its end. What is bounded is each storage call and hook
5929                // inside it ([`CallBound`]): one that never answers is that
5930                // call's FAULT, the submit handles it as any fault (nothing
5931                // of the transaction held, or all of it), the transaction
5932                // did not land, its UTXO fails and the cursor stays.
5933                let outcome = {
5934                    let _writing = self.finalize_gate.write_section();
5935                    let bound = self
5936                        .finalize_submit_budget
5937                        .as_ref()
5938                        .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5939                    self.submit_bounded(
5940                        &tagged,
5941                        SubmitMode::HistoricalTxNoSpv,
5942                        bound.as_ref(),
5943                        true,
5944                        WalkBreachPolicy::NotNow,
5945                    )
5946                    .await
5947                };
5948                let not_landed = match outcome {
5949                    Ok((_, report)) => {
5950                        let landed = report.is_durable()
5951                            && (report.applied_topics.contains(&graph.topic)
5952                                || report.deduped_topics.contains(&graph.topic));
5953                        (!landed).then(|| {
5954                            if report.is_durable() {
5955                                "the topic manager failed on it".to_string()
5956                            } else {
5957                                format!("not durable: {}", report.summary())
5958                            }
5959                        })
5960                    }
5961                    Err(e) => Some(e.to_string()),
5962                };
5963                if let Some(why) = not_landed {
5964                    warn!(
5965                        "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
5966                        graph.topic
5967                    );
5968                    all_landed = false;
5969                    break;
5970                }
5971            }
5972        }
5973
5974        if !finalized.is_empty() {
5975            info!(
5976                "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
5977                finalized.len()
5978            );
5979        }
5980        all_landed
5981    }
5982
5983    /// Union local records, tracker answers, SLAP-named providers and bootstrap.
5984    /// Reference: overlay-services@480d77a src/Engine.ts:839-876 and
5985    /// ts-stack@edf6e03 packages/sdk/src/overlay-tools/LookupResolver.ts:1976.
5986    /// Ask the configured topic peers for held outputs, using the same SHIP
5987    /// discovery as sync. Only a header-verified proof can change stored bytes.
5988    pub async fn rerequest_held_proofs(
5989        &self,
5990        candidates: &[crate::proof_window::HeldOutput],
5991        progress: &std::cell::Cell<crate::proof_window::PeerProofSummary>,
5992    ) {
5993        use crate::proof_window::{PEER_PROOF_REQUEST_MS, PEER_PROOF_ROW_LIMIT};
5994        let Some(factory) = &self.gasp_remote_factory else {
5995            return;
5996        };
5997        let Some(tracker) = self.chain_tracker.as_deref() else {
5998            return;
5999        };
6000        let mut lookup_hosts = None;
6001        let mut topic_peers = HashMap::new();
6002        let mut landed = HashSet::new();
6003        for candidate in candidates.iter().take(PEER_PROOF_ROW_LIMIT) {
6004            if !self.managers.contains_key(&candidate.topic) {
6005                continue;
6006            }
6007            if let std::collections::hash_map::Entry::Vacant(entry) =
6008                topic_peers.entry(candidate.topic.clone())
6009            {
6010                let peers = match self.config.sync_configuration.get(&candidate.topic) {
6011                    Some(SyncTarget::Ship) => {
6012                        self.discover_ship_peers(&candidate.topic, &mut lookup_hosts)
6013                            .await
6014                    }
6015                    Some(SyncTarget::Peers(peers)) => self.ship_peers_excluding_self(peers.clone()),
6016                    _ => Vec::new(),
6017                };
6018                entry.insert(peers);
6019            }
6020            for peer in &topic_peers[&candidate.topic] {
6021                let remote = factory.create_remote(peer, &candidate.topic);
6022                let graph_id = format!("{}.{}", candidate.txid, candidate.output_index);
6023                let mut summary = progress.get();
6024                summary.requests += 1;
6025                progress.set(summary);
6026                let ask =
6027                    remote.request_node(&graph_id, &candidate.txid, candidate.output_index, false);
6028                let response = if let Some((sleep, _)) = &self.peer_sync_budget {
6029                    crate::gasp::race_or_deadline(ask, sleep(PEER_PROOF_REQUEST_MS)).await
6030                } else {
6031                    Some(ask.await)
6032                };
6033                let Some(Ok(node)) = response else {
6034                    let mut summary = progress.get();
6035                    summary.faults += 1;
6036                    progress.set(summary);
6037                    continue;
6038                };
6039                if landed.contains(&candidate.txid) {
6040                    continue;
6041                }
6042                let Some(proof_hex) = node.proof else {
6043                    continue;
6044                };
6045                let Ok(mut proof) = beef_limits::merkle_path_from_hex(
6046                    &proof_hex,
6047                    beef_limits::COURIER_PROOF_MAX_BYTES,
6048                ) else {
6049                    let mut summary = progress.get();
6050                    summary.refused += 1;
6051                    progress.set(summary);
6052                    continue;
6053                };
6054                let valid = match proof.compute_root(Some(&candidate.txid)) {
6055                    Ok(root) => tracker
6056                        .is_valid_root_for_height(&root, proof.block_height)
6057                        .await
6058                        .unwrap_or(false),
6059                    Err(_) => false,
6060                };
6061                if !valid {
6062                    let mut summary = progress.get();
6063                    summary.refused += 1;
6064                    progress.set(summary);
6065                    continue;
6066                }
6067                // Root verification accepts a hash leaf too. Mark the verified
6068                // subject so BEEF attaches the path before its proof flag moves.
6069                for leaf in &mut proof.path[0] {
6070                    if leaf.hash.as_deref() == Some(&candidate.txid) {
6071                        leaf.txid = true;
6072                    }
6073                }
6074                match self.land_peer_proof(candidate, &proof).await {
6075                    Ok(()) => {
6076                        landed.insert(candidate.txid.clone());
6077                        let mut summary = progress.get();
6078                        summary.landed += 1;
6079                        progress.set(summary);
6080                    }
6081                    Err(error) => {
6082                        warn!(%error, "[proof window] verified proof could not be stored");
6083                        let mut summary = progress.get();
6084                        summary.faults += 1;
6085                        progress.set(summary);
6086                    }
6087                }
6088            }
6089        }
6090    }
6091
6092    async fn land_peer_proof(
6093        &self,
6094        candidate: &crate::proof_window::HeldOutput,
6095        proof: &bsv_rs::transaction::MerklePath,
6096    ) -> Result<(), EngineError> {
6097        let output = self
6098            .storage
6099            .find_output(
6100                &candidate.txid,
6101                candidate.output_index,
6102                Some(&candidate.topic),
6103                None,
6104                true,
6105            )
6106            .await
6107            .map_err(|e| EngineError::StorageError(e.to_string()))?
6108            .ok_or(EngineError::NodeNotFound)?;
6109        let beef = output
6110            .beef
6111            .ok_or_else(|| EngineError::BeefParseError("missing stored BEEF".into()))?;
6112        let stitched = Self::stitch_proof_into_stored_beef(&beef, &candidate.txid, proof)
6113            .ok_or_else(|| {
6114                EngineError::BeefParseError("proof does not stitch into stored subject".into())
6115            })?;
6116        self.storage
6117            .update_transaction_beef(&candidate.txid, &stitched)
6118            .await
6119            .map_err(|e| EngineError::StorageError(e.to_string()))?;
6120        self.storage
6121            .mark_transaction_proven_at(&candidate.txid, Some(u64::from(proof.block_height)))
6122            .await
6123            .map_err(|e| EngineError::StorageError(e.to_string()))
6124    }
6125
6126    /// Read our admitted advertisement set, not a foreign history. A changed
6127    /// SLAP outpoint wakes its origin; SHIP scopes that wake to its topic.
6128    async fn peer_advertisement_versions(&self, topic: &str) -> HashMap<String, String> {
6129        let mut versions: HashMap<String, Vec<String>> = HashMap::new();
6130        for (ad_topic, protocol, scope) in [
6131            ("tm_slap", &b"SLAP"[..], None),
6132            ("tm_ship", &b"SHIP"[..], Some(topic)),
6133        ] {
6134            let rows = self
6135                .storage
6136                .find_utxos_for_topic(ad_topic, None, None, false)
6137                .await
6138                .unwrap_or_default();
6139            for row in rows {
6140                if let Some(domain) =
6141                    parse_advertisement_domain_from_script(&row.output_script, protocol, scope)
6142                {
6143                    versions
6144                        .entry(crate::gasp::peer_origin(&domain))
6145                        .or_default()
6146                        .push(format!("{}:{}", row.txid, row.output_index));
6147                }
6148            }
6149        }
6150        versions
6151            .into_iter()
6152            .map(|(peer, mut keys)| {
6153                keys.sort();
6154                (peer, keys.join(","))
6155            })
6156            .collect()
6157    }
6158
6159    async fn discover_ship_peers(
6160        &self,
6161        topic: &str,
6162        lookup_hosts: &mut Option<Vec<String>>,
6163    ) -> Vec<String> {
6164        let mut domains = self.local_ship_peers(topic).await;
6165        if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
6166            domains.extend(bootstrap.iter().cloned());
6167        }
6168        if let Some(client) = &self.ship_lookup_client {
6169            if lookup_hosts.is_none() {
6170                *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
6171            }
6172            let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
6173            for host in lookup_hosts.as_ref().into_iter().flatten() {
6174                crate::meter::peer_request();
6175                match client.lookup(host, &question).await {
6176                    Ok(outputs) => {
6177                        domains.extend(outputs.iter().filter_map(|output| {
6178                            parse_lookup_advertisement(output, b"SHIP", topic)
6179                        }));
6180                    }
6181                    Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
6182                }
6183            }
6184        }
6185        self.ship_peers_excluding_self(domains)
6186    }
6187
6188    async fn discover_ship_lookup_hosts(
6189        &self,
6190        client: &dyn crate::ship_lookup::ShipLookupClient,
6191    ) -> Vec<String> {
6192        use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
6193        let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
6194        let tracker_origins: HashSet<_> = trackers
6195            .iter()
6196            .map(|host| crate::gasp::peer_origin(host))
6197            .collect();
6198        let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
6199        let mut advertised = Vec::new();
6200        for tracker in &trackers {
6201            crate::meter::peer_request();
6202            match client.lookup(tracker, &question).await {
6203                Ok(outputs) => {
6204                    advertised.extend(outputs.iter().filter_map(|output| {
6205                        parse_lookup_advertisement(output, b"SLAP", "ls_ship")
6206                    }));
6207                }
6208                Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
6209            }
6210        }
6211        let providers = self
6212            .ship_peers_excluding_self(advertised)
6213            .into_iter()
6214            .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
6215            .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
6216        trackers.into_iter().chain(providers).collect()
6217    }
6218
6219    fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
6220        crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
6221            self.config.hosting_url.as_ref().is_none_or(|our_url| {
6222                crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
6223            })
6224        }))
6225    }
6226
6227    /// Read local SHIP records independently of network discovery failures.
6228    async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
6229        let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
6230            warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
6231            return Vec::new();
6232        };
6233
6234        let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
6235
6236        let refs = match ship_ls.lookup(&question).await {
6237            Ok(LookupResult::OutputList(refs)) => refs,
6238            Ok(LookupResult::Answer(_)) => {
6239                error!(
6240                    "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
6241                     LookupAnswer; expected OutputList. Skipping."
6242                );
6243                return Vec::new();
6244            }
6245            Err(e) => {
6246                error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
6247                return Vec::new();
6248            }
6249        };
6250
6251        let mut domains = HashSet::new();
6252        for reference in &refs {
6253            if let Ok(Some(output)) = self
6254                .storage
6255                .find_output(&reference.txid, reference.output_index, None, None, false)
6256                .await
6257            {
6258                if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
6259                    // Every spelling of our own origin is ourselves (the
6260                    // delta-2 fold's D2-M2: `https://us/?x` passed the
6261                    // string compare).
6262                    if let Some(ref our_url) = self.config.hosting_url {
6263                        if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
6264                            || crate::gasp::peer_origin(&domain)
6265                                == crate::gasp::peer_origin(our_url)
6266                        {
6267                            continue;
6268                        }
6269                    }
6270                    domains.insert(domain);
6271                }
6272            }
6273        }
6274
6275        // One peer per normalized origin (bsv-low #555, the delta-2 fold's
6276        // D2-M2, restored by the delta-4 fold's D4-M1): eight spellings of
6277        // one server are one peer, the one its health row is keyed by.
6278        crate::gasp::ship_peers_by_origin(domains)
6279    }
6280
6281    // ========================================================================
6282    // Helpers
6283    // ========================================================================
6284
6285    /// Get a reference to the storage backend.
6286    pub fn storage(&self) -> &dyn Storage {
6287        self.storage.as_ref()
6288    }
6289
6290    /// Get the engine configuration.
6291    pub fn config(&self) -> &EngineConfig {
6292        &self.config
6293    }
6294}
6295
6296/// The SPV check of [`Engine::submit`], as one function over a BORROWED
6297/// tracker: the reference's `tx.verify(chainTracker)` (roots against the
6298/// tracker, every unproven input's script executed, the value rule), its
6299/// `tx.verify('scripts only')` when `chain_tracker` is `None` (roots accepted
6300/// unchecked, scripts still run), and the pre-2026-09-08 structural check when
6301/// `verify_scripts` is `false` ([`Engine::set_script_verification`]'s escape
6302/// hatch).
6303///
6304/// There is ONE verifier. `Engine::submit` calls this with its own tracker,
6305/// switch and walk budget; the GASP anchor check
6306/// (`OverlayGASPStorage::validate_graph_anchor`, bsv-low #551) calls it with
6307/// the same three, handed over by `Engine::start_gasp_sync`.
6308///
6309/// `Ok(Some(_))` is a walk that COULD NOT RUN within `budget` (bsv-low #592):
6310/// not a verdict on the transaction. The caller goes on as it would without a
6311/// walk and says so.
6312pub(crate) async fn verify_spv_like_the_reference(
6313    chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6314    verify_scripts: bool,
6315    beef_bytes: &[u8],
6316    subject_txid: &str,
6317    budget: DoorBudget,
6318) -> Result<Option<WalkCouldNotRun>, EngineError> {
6319    verify_spv_trusting(
6320        chain_tracker,
6321        verify_scripts,
6322        beef_bytes,
6323        subject_txid,
6324        &HashSet::new(),
6325        budget,
6326    )
6327    .await
6328}
6329
6330/// [`verify_spv_like_the_reference`] trusting the transactions an earlier
6331/// walk of the same submit passed over (a predecessor the door lands first,
6332/// the E1D delta fold, L4): the script walk neither checks nor descends
6333/// them. The structural escape hatch checks the whole BEEF it is given.
6334async fn verify_spv_trusting(
6335    chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6336    verify_scripts: bool,
6337    beef_bytes: &[u8],
6338    subject_txid: &str,
6339    trusted: &HashSet<String>,
6340    budget: DoorBudget,
6341) -> Result<Option<WalkCouldNotRun>, EngineError> {
6342    if verify_scripts {
6343        Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
6344    } else {
6345        Engine::verify_spv_structurally(chain_tracker, beef_bytes)
6346            .await
6347            .map(|()| None)
6348    }
6349}
6350
6351/// The BUMP that proves each txid of `beef`: the first BUMP whose level 0
6352/// carries it, as `Beef::find_bump` picks, built in one pass over the BUMPs'
6353/// leaves (NL-6e; `find_bump` per transaction scans them all each time).
6354fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
6355    let mut proven: HashMap<&str, usize> = HashMap::new();
6356    for (at, bump) in beef.bumps.iter().enumerate() {
6357        for leaf in bump.path.first().into_iter().flatten() {
6358            if let Some(hash) = leaf.hash.as_deref() {
6359                proven.entry(hash).or_insert(at);
6360            }
6361        }
6362    }
6363    proven
6364}
6365
6366/// The transactions the SPV walk of `from` passes over in `beef`
6367/// ([`Engine::verify_beef_linear_with`]'s traversal, nothing checked): `from`
6368/// and every source the BEEF carries, a proven transaction included and not
6369/// descended. The E1D delta fold, L4: what a landing need not walk again.
6370fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
6371    let bodies: HashMap<String, &Transaction> = beef
6372        .txs
6373        .iter()
6374        .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
6375        .collect();
6376    let proven = proven_index(beef);
6377    let mut cover = HashSet::new();
6378    let mut queue = vec![from.to_string()];
6379    while let Some(txid) = queue.pop() {
6380        if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
6381            continue;
6382        }
6383        if let Some(tx) = bodies.get(&txid) {
6384            queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
6385        }
6386    }
6387    cover
6388}
6389
6390/// The engine's [`crate::gasp::FinalizedGraphHook`] (bsv-low #552): submit
6391/// what a peer's sync just finalized, before its next UTXO is asked for.
6392struct SubmitAsFinalized<'e> {
6393    engine: &'e Engine,
6394    sink: crate::gasp_overlay::FinalizedGraphSink,
6395    peer_url: &'e str,
6396    topic: &'e str,
6397    submitted: &'e std::cell::Cell<u64>,
6398}
6399
6400#[async_trait::async_trait(?Send)]
6401impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
6402    async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
6403        // A graph is counted as its first submit starts, and the deadline
6404        // cannot land inside a submit: a sync dropped in here has written at
6405        // least that transaction whole (an ancestors-first prefix).
6406        let landed = self
6407            .engine
6408            .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
6409            .await;
6410        if landed {
6411            Ok(())
6412        } else {
6413            // Fails this UTXO: the cursor stays below it and the next sync
6414            // asks for it again (the lens fold's MEDIUM-1).
6415            Err(crate::gasp::GASPError::StorageError(
6416                "a finalize submit did not land".to_string(),
6417            ))
6418        }
6419    }
6420}
6421
6422/// Result of a GASP sync operation.
6423///
6424/// Returned by `Engine::start_gasp_sync()` to summarize what happened.
6425#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6426pub struct GASPSyncResult {
6427    #[serde(default, flatten)]
6428    pub cost: crate::meter::SyncCost,
6429    #[serde(default)]
6430    pub filter: crate::sync_load::SyncFilter,
6431    #[serde(default)]
6432    pub topics_filtered: Vec<String>,
6433    /// Per-topic sync results.
6434    pub topics_synced: HashMap<String, TopicSyncResult>,
6435}
6436
6437/// Sync result for a single topic.
6438#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6439pub struct TopicSyncResult {
6440    #[serde(default)]
6441    pub peer_results: HashMap<String, crate::backfill::PeerSyncResult>,
6442    #[serde(default)]
6443    pub dead_graphs: u64,
6444    #[serde(default)]
6445    pub expired_swept: u64,
6446    #[serde(default)]
6447    pub memo_folded: u64,
6448    #[serde(default, flatten)]
6449    pub cost: crate::meter::SyncCost,
6450    #[serde(default)]
6451    pub root_bounds: Vec<crate::sync_load::RootBound>,
6452    #[serde(default)]
6453    pub peers_skipped: Vec<crate::sync_load::SkippedPeer>,
6454    #[serde(default)]
6455    pub peers_filtered: Vec<String>,
6456    /// Root proof status after successful anchor validation and finalization.
6457    #[serde(default)]
6458    pub roots_proven: u64,
6459    #[serde(default)]
6460    pub roots_unproven: u64,
6461    /// Outstanding outpoints on peers whose cursor did not advance.
6462    #[serde(default)]
6463    pub cursor_held_by: Vec<String>,
6464    /// Distinct expired advert roots skipped before any proof request.
6465    #[serde(default)]
6466    pub expired_refused: u64,
6467    /// Peer URLs discovered or configured for this topic.
6468    pub peers: Vec<String>,
6469    /// How peers were determined: "ship" or "peers".
6470    pub sync_type: String,
6471    /// Any errors encountered during sync (peer URL -> error message): a
6472    /// peer whose sync failed or ran over its budget. A single UTXO whose
6473    /// graph ingest failed is NOT listed here: it is warned (`Error ingesting
6474    /// UTXO`), held back by the cursor gap guard and asked for again by the
6475    /// next sync.
6476    pub errors: Vec<String>,
6477    /// Manager-named inputs pruned because a peer answered that it does not
6478    /// hold them (the D8 decoy rule, `GASPSync::pruned_inputs`). Counted per
6479    /// distinct outpoint PER PEER per sync and summed over this topic's
6480    /// peers: one decoy seen through two peers reads 2. Not errors: the
6481    /// graphs completed without those branches.
6482    #[serde(default)]
6483    pub pruned_inputs: u64,
6484    /// Graphs the anchor check REFUSED and discarded whole (bsv-low #551,
6485    /// `GASPSync::discarded_graphs`): a root whose BEEF does not verify, or
6486    /// one the topic manager does not admit at the end of the replay. Summed
6487    /// over this topic's peers. Not errors, and nothing of them was admitted.
6488    #[serde(default)]
6489    pub discarded_graphs: u64,
6490    /// Graphs HELD because their anchor's script walk could not run within
6491    /// the engine's walk budget (bsv-low #592; the E592 lens fold, H1 and
6492    /// L3; `GASPSync::anchor_walk_held`), summed over this topic's peers.
6493    /// Nothing of them was finalized; each UTXO stays below its cursor until
6494    /// the peer serves its root proven. Not errors and not discarded.
6495    #[serde(default)]
6496    pub anchor_walk_held_graphs: u64,
6497    /// Graphs that passed the anchor check and were handed to submit, summed
6498    /// over this topic's peers (bsv-low #552). Under a per-peer budget a
6499    /// graph is counted, and submitted, the moment it finalizes, so the
6500    /// count includes those of a peer the deadline then dropped. A counted
6501    /// graph had its first transaction submitted whole (the deadline never
6502    /// lands inside a submit); one whose later submit did not land is still
6503    /// counted, and its UTXO stays below the cursor.
6504    /// A graph is counted as its first submit STARTS; a first submit that FAULTS
6505    /// still counts here, so this is not a delivered count (bsv-low #559).
6506    #[serde(default)]
6507    pub finalized_graphs: u64,
6508    /// Graphs that were mid-walk when a peer's sync was dropped at its
6509    /// budget (bsv-low #552): at most one per peer per sync. NOTHING of such
6510    /// a graph was admitted; the next sync walks it again. The same root
6511    /// counted tick after tick with no `finalized_graphs` and no
6512    /// `cursor_moves` is ONE graph whose walk outlasts the budget (with a
6513    /// per-graph budget it is also counted in `deferred_graphs` and resumed,
6514    /// bsv-low #555).
6515    #[serde(default)]
6516    pub deadline_dropped_graphs: u64,
6517    /// Every persisted cursor that moved in this sync (bsv-low #552), one
6518    /// entry per peer. After a completed sync `to` is the peer's highest
6519    /// score seen (less the gap guard); after a deadline it is
6520    /// `GASPSync::completed_cursor`: past completed UTXOs only.
6521    #[serde(default)]
6522    pub cursor_moves: Vec<CursorMove>,
6523    /// Graphs DEFERRED in this sync, summed over peers (bsv-low #555,
6524    /// `Engine::set_graph_budget`): a new record, or a resumed graph
6525    /// deferred again. Their UTXOs are held below the cursor.
6526    #[serde(default)]
6527    pub deferred_graphs: u64,
6528    /// Graphs RESUMED from a record in this sync.
6529    #[serde(default)]
6530    pub resumed_graphs: u64,
6531    /// Resumed graphs that completed and landed in this sync (their record
6532    /// deleted).
6533    #[serde(default)]
6534    pub converged_graphs: u64,
6535    /// Records deleted without converging, with their reason.
6536    #[serde(default)]
6537    pub dropped_graphs: Vec<DroppedDeferral>,
6538    /// Walks the per-graph budget (or the per-peer deadline) cut having
6539    /// fetched NOTHING in their pass (bsv-low #555, the lens fold's H1). A
6540    /// peer's sync whose only outcome was these is a failed attempt for the
6541    /// quarantine count.
6542    #[serde(default)]
6543    pub stalled_graphs: u64,
6544    /// Records NOT resumed in this sync: the resumes of a peer's pass share
6545    /// ONE per-graph budget and had spent it (the lens fold's M1). Held for
6546    /// the next sync, untouched.
6547    #[serde(default)]
6548    pub held_back_graphs: u64,
6549    /// Held records this sync sent to the long backoff (engine 0.1.10,
6550    /// `gasp::IDLE_RESUMES_BEFORE_BACKOFF`): resumed that many passes in a
6551    /// row with no node appended, not resumed again for
6552    /// `gasp::IDLE_RESUME_RETRY_SECS`.
6553    #[serde(default)]
6554    pub backed_off_graphs: u64,
6555    /// Held records this sync did not resume because they wait out that
6556    /// backoff: no request, the cursor passes them (engine 0.1.10).
6557    #[serde(default)]
6558    pub waiting_graphs: u64,
6559    /// Held records whose resumed pass this sync counted one idle resume
6560    /// on (engine 0.1.11, `gasp::DeferralStats::idle_counted`): the pass
6561    /// asked the peer and appended nothing it kept, whether it ended in a
6562    /// save, a drop or an error. A captain reads the idle rule firing here.
6563    #[serde(default)]
6564    pub idle_counted: u64,
6565    /// Roots recorded UNANCHORABLE in this sync (engine 0.1.9,
6566    /// `GASPSync::unanchorable_recorded`): their walk faulted in
6567    /// `gasp::UNANCHORABLE_AFTER_FAULTED_RUNS` consecutive runs; the cursor
6568    /// passes them, and each is walked again after
6569    /// `gasp::UNANCHORABLE_RETRY_SECS`. Nothing of them is admitted.
6570    #[serde(default)]
6571    pub unanchorable_graphs: u64,
6572    /// Roots struck `unanchorable:not_found` in this sync (engine 0.1.15,
6573    /// `GASPSync::absent_struck`): the advertising peer said it does not
6574    /// hold the root, or a transaction an unproven node of its graph needs,
6575    /// on `gasp::UNANCHORABLE_AFTER_FAULTED_RUNS` runs at least
6576    /// `gasp::ABSENCE_GAP_SECS` apart. Counted in `unanchorable_graphs` too.
6577    #[serde(default)]
6578    pub absent_struck: u64,
6579    /// Syncs a ROUTE FAULT ended (engine 0.2.0, bsv-overlay-cloudflare #57;
6580    /// [`crate::route_fault`]): the platform answered before the peer was
6581    /// reached. Each names its peer and class; none counted against the
6582    /// peer's health unless the peer had served work before it.
6583    #[serde(default)]
6584    pub route_faults: Vec<crate::route_fault::RouteFaultSeen>,
6585    /// Roots with a fault record that this sync walked again and admitted,
6586    /// or refused on a verdict: their record cleared (engine 0.1.9).
6587    #[serde(default)]
6588    pub unanchorable_cleared: u64,
6589    /// Strikes this sync could not write (engine 0.1.10,
6590    /// `GASPSync::strikes_refused`):
6591    /// at the storage's ceiling or on its fault. Each root is named in
6592    /// `faulted_roots` with its `refused` reason.
6593    #[serde(default)]
6594    pub strikes_refused: u64,
6595    /// Every root a peer's sync dropped in this run, once per (peer, root),
6596    /// named with its fault class, its faulted runs and whether it is now
6597    /// unanchorable (engine 0.1.9, `GASPSync::faulted_roots`). With
6598    /// `cursor_held_by` (what is neither finished nor held) the captain
6599    /// reads why the cursor stopped or moved.
6600    #[serde(default)]
6601    pub faulted_roots: Vec<crate::gasp::FaultedRoot>,
6602}
6603
6604/// A deferred graph's record deleted without converging (bsv-low #555,
6605/// [`TopicSyncResult::dropped_graphs`]).
6606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6607pub struct DroppedDeferral {
6608    /// The peer URL.
6609    pub peer: String,
6610    /// The graph's root outpoint.
6611    pub outpoint: String,
6612    /// [`crate::gasp::DropReason::as_str`].
6613    pub reason: String,
6614}
6615
6616/// One peer's persisted `last_interaction` cursor moving `from` -> `to` in a
6617/// sync ([`TopicSyncResult::cursor_moves`]).
6618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6619pub struct CursorMove {
6620    /// The peer URL.
6621    pub peer: String,
6622    /// The cursor the sync entered with.
6623    pub from: u64,
6624    /// The cursor it persisted.
6625    pub to: u64,
6626}
6627
6628/// Get current time in milliseconds (for output scores).
6629///
6630/// Uses `js_sys::Date::now()` on wasm32 (Cloudflare Workers) since
6631/// `std::time::SystemTime` is not available on that platform.
6632pub(crate) fn current_timestamp_ms() -> f64 {
6633    #[cfg(target_arch = "wasm32")]
6634    {
6635        js_sys::Date::now()
6636    }
6637    #[cfg(not(target_arch = "wasm32"))]
6638    {
6639        std::time::SystemTime::now()
6640            .duration_since(std::time::UNIX_EPOCH)
6641            .map_or(0.0, |d| d.as_millis() as f64)
6642    }
6643}
6644
6645/// Extract the domain field from a SHIP PushDrop locking script.
6646///
6647/// SHIP PushDrop format: field[0] = "SHIP", field[1] = identity_key,
6648/// field[2] = domain (UTF-8), field[3] = topic.
6649/// Returns `None` if the script cannot be parsed or is not a SHIP advertisement.
6650fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
6651    parse_advertisement_domain_from_script(output_script, b"SHIP", None)
6652}
6653
6654fn parse_lookup_advertisement(
6655    output: &OutputListItem,
6656    protocol: &[u8],
6657    scope: &str,
6658) -> Option<String> {
6659    let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
6660        .ok()?;
6661    let script = tx
6662        .outputs
6663        .get(output.output_index as usize)?
6664        .locking_script
6665        .to_binary();
6666    parse_advertisement_domain_from_script(&script, protocol, Some(scope))
6667}
6668
6669fn parse_advertisement_domain_from_script(
6670    output_script: &[u8],
6671    protocol: &[u8],
6672    scope: Option<&str>,
6673) -> Option<String> {
6674    use bsv_rs::script::templates::PushDrop;
6675
6676    let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
6677    let pushdrop = PushDrop::decode(&script.into()).ok()?;
6678
6679    if pushdrop.fields.len() < 3 {
6680        return None;
6681    }
6682
6683    if pushdrop.fields[0] != protocol {
6684        return None;
6685    }
6686
6687    if scope.is_some_and(|expected| {
6688        pushdrop
6689            .fields
6690            .get(3)
6691            .is_none_or(|actual| actual != expected.as_bytes())
6692    }) {
6693        return None;
6694    }
6695
6696    let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
6697    if domain.is_empty() {
6698        return None;
6699    }
6700
6701    Some(domain)
6702}
6703
6704// ============================================================================
6705// Submit-time spend verification helpers (reference parity, 2026-09-08)
6706// ============================================================================
6707
6708// ============================================================================
6709// Error type
6710// ============================================================================
6711
6712#[derive(Debug, thiserror::Error)]
6713pub enum EngineError {
6714    #[error("This server does not support this topic: {0}")]
6715    UnsupportedTopic(String),
6716
6717    #[error("Lookup service not found for provider: {0}")]
6718    LookupServiceNotFound(String),
6719
6720    /// GASP: `/requestForeignGASPNode` asked for a graph/txid we don't
6721    /// have locally. Matches mainline's 400-class "not found" response.
6722    #[error("No matching output found!")]
6723    NodeNotFound,
6724
6725    #[error("lookup failed: {0}")]
6726    LookupFailed(String),
6727
6728    /// The CALLER's query was malformed — a bad identity key, a gameId that is
6729    /// not 32 bytes of hex, a missing required field. Distinct from
6730    /// `LookupFailed`, which means WE failed: without the distinction every
6731    /// caller mistake was reported as a server fault (HTTP 500), so a malformed
6732    /// query and a genuine outage were indistinguishable in logs and alerts on
6733    /// an unauthenticated endpoint anyone can hit.
6734    #[error("invalid query: {0}")]
6735    InvalidQuery(String),
6736
6737    #[error("storage error: {0}")]
6738    StorageError(String),
6739
6740    #[error("broadcast error: {0}")]
6741    BroadcastError(String),
6742
6743    #[error("SPV verification failed: {0}")]
6744    SpvError(String),
6745
6746    #[error("BEEF parsing failed: {0}")]
6747    BeefParseError(String),
6748
6749    /// A spend in the submitted BEEF does not satisfy its source output's
6750    /// locking script: the input's unlocking script was EXECUTED (reference
6751    /// parity, 2026-09-08) and the interpreter refused it. Distinct from
6752    /// [`EngineError::SpvError`] (a bad or unverifiable PROOF, a missing
6753    /// source, a chain-tracker fault) so an operator can tell a bad spend from
6754    /// a bad proof. `input_index` is the failing input's position in the
6755    /// transaction whose script failed (the subject, or an unproven
6756    /// ancestor of it in the same BEEF); `reason` is the interpreter's own
6757    /// message.
6758    #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6759    ScriptVerificationFailed {
6760        subject_txid: String,
6761        input_index: u32,
6762        reason: String,
6763    },
6764
6765    /// The DOOR walk ([`Engine::verify_scripts_only`]) hit a STRUCTURAL fault
6766    /// before reaching a verdict on `at_txid` (a source the BEEF lacks, a
6767    /// parse fault, the value rule): not the interpreter's verdict, so not a
6768    /// refusal for a caller whose bar is the network. `subject_judged` says
6769    /// whether the subject's own inputs had all executed before the fault
6770    /// (an ancestor faulted) or not (the subject itself is unjudged).
6771    #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6772    ScriptWalkInconclusive {
6773        at_txid: String,
6774        subject_judged: bool,
6775        reason: String,
6776    },
6777
6778    /// The DOOR walk exceeded its own bound ([`DoorBudget`]: the static work
6779    /// estimate, the interpreter's memory limit tripping, or the estimate of
6780    /// the door's own memory; `limb` says which): the door's verdict, never
6781    /// the network's — the request proceeds to the network.
6782    #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6783    ScriptWalkOverBudget {
6784        at_txid: String,
6785        subject_judged: bool,
6786        limb: DoorLimb,
6787        what: String,
6788    },
6789
6790    /// The submit's own script walk COULD NOT RUN within the engine's
6791    /// [`DoorBudget`] (bsv-low #592) and nothing on record stands in for it
6792    /// ([`WalkBreachPolicy::NotNow`], every door but the queue replay of a
6793    /// gated submission; the E592 lens fold, H1). NOT NOW: nothing was
6794    /// judged, nothing written, and it is NOT a refusal either: the budget
6795    /// says nothing about the transaction. The way out is the subject
6796    /// PROVEN: a proven transaction is not walked, so an honest body too
6797    /// big to walk unmined lands once its block is mined (a client retries
6798    /// after the block, not at once).
6799    #[error(
6800        "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6801        .0.limb.as_str(),
6802        .0.what
6803    )]
6804    WalkCouldNotRun(WalkCouldNotRun),
6805
6806    #[error("{0}")]
6807    Other(String),
6808}
6809
6810impl From<StorageError> for EngineError {
6811    fn from(e: StorageError) -> Self {
6812        EngineError::StorageError(e.to_string())
6813    }
6814}
6815
6816impl From<TopicManagerError> for EngineError {
6817    fn from(e: TopicManagerError) -> Self {
6818        EngineError::Other(e.to_string())
6819    }
6820}
6821
6822impl From<LookupServiceError> for EngineError {
6823    fn from(e: LookupServiceError) -> Self {
6824        EngineError::LookupFailed(e.to_string())
6825    }
6826}
6827
6828impl From<AdvertiserError> for EngineError {
6829    fn from(e: AdvertiserError) -> Self {
6830        EngineError::Other(e.to_string())
6831    }
6832}
6833
6834// ============================================================================
6835// Tests
6836// ============================================================================
6837
6838/// Baseline sync order: configured peer topics before SHIP discovery topics,
6839/// each by name (bsv-low #555, D-M1). `start_gasp_sync` advances SHIP topics
6840/// with explicit bootstrap peers ahead of this order so their sync can run
6841/// without importing tracker records. Deferred-graph reservations still use
6842/// each topic's configured target.
6843pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6844    let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6845    order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6846    order
6847}
6848
6849#[cfg(test)]
6850mod tests {
6851    use super::*;
6852    use crate::lookup_service::LookupService as LookupServiceTrait;
6853    use crate::storage::memory::MemoryStorage;
6854    use crate::topic_manager::TopicManager as TopicManagerTrait;
6855    use async_trait::async_trait;
6856    use std::sync::Mutex;
6857
6858    // bsv-low #555, the delta fold's D-M1: the configured peers' topics are
6859    // synced before the ls_ship-discovered ones, each class by name; the map's
6860    // order was arbitrary.
6861    #[test]
6862    fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6863        let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6864        let config: SyncConfiguration = HashMap::from([
6865            ("tm_a".to_string(), SyncTarget::Ship),
6866            ("tm_z".to_string(), peers("https://z")),
6867            ("tm_b".to_string(), SyncTarget::Ship),
6868            ("tm_m".to_string(), peers("https://m")),
6869            ("tm_off".to_string(), SyncTarget::Disabled),
6870        ]);
6871        let order: Vec<&str> = sync_order(&config)
6872            .iter()
6873            .map(|(t, _)| t.as_str())
6874            .collect();
6875        assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6876    }
6877
6878    // ── Mock TopicManager ──────────────────────────────────────────────
6879
6880    struct MockTopicManager {
6881        admit_indices: Vec<u32>,
6882    }
6883
6884    impl MockTopicManager {
6885        fn admitting(indices: Vec<u32>) -> Self {
6886            Self {
6887                admit_indices: indices,
6888            }
6889        }
6890    }
6891
6892    #[async_trait(?Send)]
6893    impl TopicManagerTrait for MockTopicManager {
6894        async fn identify_admissible_outputs(
6895            &self,
6896            _tx: &Transaction,
6897            _previous_coins: &[u8],
6898            _off_chain_values: Option<&[u8]>,
6899            _mode: SubmitMode,
6900            _context: &TopicAdmittanceContext,
6901        ) -> Result<AdmittanceInstructions, TopicManagerError> {
6902            Ok(AdmittanceInstructions {
6903                outputs_to_admit: self.admit_indices.clone(),
6904                coins_to_retain: vec![],
6905                coins_removed: None,
6906            })
6907        }
6908
6909        async fn get_documentation(&self) -> String {
6910            "Mock topic manager".to_string()
6911        }
6912
6913        async fn get_metadata(&self) -> ServiceMetadata {
6914            ServiceMetadata {
6915                name: "mock-tm".to_string(),
6916                description: Some("Mock for testing".to_string()),
6917                ..Default::default()
6918            }
6919        }
6920    }
6921
6922    // ── Mock LookupService ─────────────────────────────────────────────
6923
6924    struct MockLookupService {
6925        records: Mutex<Vec<UTXOReference>>,
6926    }
6927
6928    impl MockLookupService {
6929        fn new() -> Self {
6930            Self {
6931                records: Mutex::new(Vec::new()),
6932            }
6933        }
6934    }
6935
6936    #[async_trait(?Send)]
6937    impl LookupServiceTrait for MockLookupService {
6938        fn admission_mode(&self) -> AdmissionMode {
6939            AdmissionMode::LockingScript
6940        }
6941
6942        fn spend_notification_mode(&self) -> SpendNotificationMode {
6943            SpendNotificationMode::None
6944        }
6945
6946        async fn output_admitted_by_topic(
6947            &self,
6948            payload: &OutputAdmittedByTopic,
6949        ) -> Result<(), LookupServiceError> {
6950            let (txid, oi) = match payload {
6951                OutputAdmittedByTopic::LockingScript {
6952                    txid, output_index, ..
6953                } => (txid.clone(), *output_index),
6954                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
6955                    ("whole".into(), *output_index)
6956                }
6957            };
6958            self.records.lock().unwrap().push(UTXOReference {
6959                txid,
6960                output_index: oi,
6961            });
6962            Ok(())
6963        }
6964
6965        async fn output_evicted(
6966            &self,
6967            txid: &str,
6968            output_index: u32,
6969        ) -> Result<(), LookupServiceError> {
6970            self.records
6971                .lock()
6972                .unwrap()
6973                .retain(|r| !(r.txid == txid && r.output_index == output_index));
6974            Ok(())
6975        }
6976
6977        async fn lookup(
6978            &self,
6979            _question: &LookupQuestion,
6980        ) -> Result<LookupResult, LookupServiceError> {
6981            Ok(LookupResult::OutputList(
6982                self.records.lock().unwrap().clone(),
6983            ))
6984        }
6985
6986        async fn get_documentation(&self) -> String {
6987            "Mock lookup service".to_string()
6988        }
6989
6990        async fn get_metadata(&self) -> ServiceMetadata {
6991            ServiceMetadata {
6992                name: "mock-ls".to_string(),
6993                ..Default::default()
6994            }
6995        }
6996    }
6997
6998    // ── Test BEEF data ──────────────────────────────────────────────────
6999
7000    /// Real BRC-62 BEEF from TS overlay-services test suite.
7001    const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
7002    const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
7003
7004    fn decode_hex(hex: &str) -> Vec<u8> {
7005        (0..hex.len())
7006            .step_by(2)
7007            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
7008            .collect()
7009    }
7010
7011    fn test_beef() -> Vec<u8> {
7012        decode_hex(TEST_BEEF_HEX)
7013    }
7014
7015    fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
7016        TaggedBEEF::new(
7017            test_beef(),
7018            topics.into_iter().map(str::to_string).collect(),
7019        )
7020    }
7021
7022    // ── Helpers ────────────────────────────────────────────────────────
7023
7024    fn make_engine(admit_indices: Vec<u32>) -> Engine {
7025        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7026        managers.insert(
7027            "tm_test".to_string(),
7028            Box::new(MockTopicManager::admitting(admit_indices)),
7029        );
7030
7031        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7032        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7033
7034        let storage = Box::new(MemoryStorage::new());
7035
7036        Engine::new(
7037            managers,
7038            lookup_services,
7039            storage,
7040            None,
7041            EngineConfig::default(),
7042        )
7043    }
7044
7045    // ── Tests ──────────────────────────────────────────────────────────
7046
7047    /// bsv-low#289 parse-once + zanaadu#284 subject discipline: the tx handed
7048    /// to every topic manager must be the ATOMIC SUBJECT of the submitted
7049    /// BEEF, not whatever sits last in wire order. bsv-rs < 0.3.20's
7050    /// `from_beef(_, None)` picked `txs.last()`; wallet serializers do not all
7051    /// place the subject last, so a TM could be handed a PARENT to validate.
7052    /// This pins the engine on the fixed rule (`txid ?? atomicTxid ?? last`).
7053    #[tokio::test]
7054    async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
7055        use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
7056        use std::cell::RefCell;
7057        use std::rc::Rc;
7058
7059        // Rebuild the fixture chain SUBJECT-FIRST via to_writer (to_binary
7060        // re-sorts parents-first and would hide the trigger).
7061        let parsed = Beef::from_binary(&test_beef()).unwrap();
7062        let parent = parsed.txs[0].tx().unwrap().clone();
7063        let child = parsed.txs[1].tx().unwrap().clone();
7064        let child_id = child.id();
7065        let parent_id = parent.id();
7066        let mut beef = Beef::new();
7067        // The parent's BUMP rides along: the reference-parity walk
7068        // (2026-09-08) executes the subject's script against the parent and
7069        // then trusts the parent on its proof; without the BUMP it would
7070        // demand the parent's own ancestry ("Input 0 has no source
7071        // transaction"), which this fixture does not carry.
7072        for bump in &parsed.bumps {
7073            beef.merge_bump(bump.clone());
7074        }
7075        beef.merge_transaction(child);
7076        beef.merge_transaction(parent);
7077        let mut w = bsv_rs::primitives::encoding::Writer::new();
7078        w.write_u32_le(ATOMIC_BEEF);
7079        let mut le = hex::decode(&child_id).unwrap();
7080        le.reverse();
7081        w.write_bytes(&le);
7082        beef.to_writer(&mut w);
7083        let atomic_wire_order = w.into_bytes();
7084
7085        // A TM that records the txid it is handed.
7086        struct RecordingTm(Rc<RefCell<Option<String>>>);
7087        #[async_trait(?Send)]
7088        impl TopicManagerTrait for RecordingTm {
7089            async fn identify_admissible_outputs(
7090                &self,
7091                tx: &Transaction,
7092                _previous_coins: &[u8],
7093                _off_chain_values: Option<&[u8]>,
7094                _mode: SubmitMode,
7095                _context: &TopicAdmittanceContext,
7096            ) -> Result<AdmittanceInstructions, TopicManagerError> {
7097                *self.0.borrow_mut() = Some(tx.id());
7098                Ok(AdmittanceInstructions {
7099                    outputs_to_admit: vec![0],
7100                    coins_to_retain: vec![],
7101                    coins_removed: None,
7102                })
7103            }
7104            async fn get_documentation(&self) -> String {
7105                String::new()
7106            }
7107            async fn get_metadata(&self) -> ServiceMetadata {
7108                ServiceMetadata {
7109                    name: "tm_test".to_string(),
7110                    ..Default::default()
7111                }
7112            }
7113        }
7114
7115        let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
7116        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7117        managers.insert(
7118            "tm_test".to_string(),
7119            Box::new(RecordingTm(Rc::clone(&seen))),
7120        );
7121        let engine = Engine::new(
7122            managers,
7123            HashMap::new(),
7124            Box::new(MemoryStorage::new()),
7125            None,
7126            EngineConfig::default(),
7127        );
7128
7129        let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
7130        let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
7131
7132        let handed = seen.borrow().clone().expect("TM was not invoked");
7133        assert_eq!(
7134            handed, child_id,
7135            "TM must be handed the atomic subject, got the wire-order pick"
7136        );
7137        assert_ne!(handed, parent_id);
7138    }
7139
7140    /// #4 REGRESSION: history hydration must never DOWNGRADE provenance.
7141    ///
7142    /// `get_utxo_history` used to `Transaction::from_beef(beef, None)` and then
7143    /// `tx.to_beef(true)`, which keeps only the subject and DROPS the stored
7144    /// BUMPs. Measured on prod before the fix: `ls_low` byGameId `73de6e48…`
7145    /// returned 1,172 B / 1 tx / 1 bump (PROVEN) with no header, and 676 B /
7146    /// 1 tx / 0 bumps with `x-history-depth: 3`. A caller asking for MORE
7147    /// provenance got a BEEF no wallet can verify — silently, behind a 200.
7148    ///
7149    /// There was NO test on this function at all, which is exactly how that
7150    /// shipped. This one asserts the invariant that matters: whatever else
7151    /// hydration does, a proof that went in comes back out.
7152    #[tokio::test]
7153    async fn history_hydration_preserves_the_stored_proof() {
7154        use bsv_rs::transaction::Beef;
7155
7156        let engine = make_engine(vec![0]);
7157        let stored = test_beef();
7158        let bumps_before = Beef::from_binary(&stored)
7159            .expect("fixture BEEF parses")
7160            .bumps
7161            .len();
7162        assert!(
7163            bumps_before > 0,
7164            "fixture must actually carry a proof, or this cell proves nothing"
7165        );
7166
7167        let output = Output {
7168            txid: TEST_TXID.to_string(),
7169            output_index: 0,
7170            output_script: vec![0x76, 0xa9],
7171            satoshis: 1000,
7172            topic: "tm_test".to_string(),
7173            spent: false,
7174            // No admitted parents: hydration has nothing to add, so anything
7175            // it REMOVES is pure loss — the cleanest form of the bug.
7176            outputs_consumed: vec![],
7177            consumed_by: vec![],
7178            beef: Some(stored.clone()),
7179            block_height: None,
7180            score: Some(1000.0),
7181        };
7182
7183        let hydrated = engine
7184            .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
7185            .await
7186            .expect("hydration must not error")
7187            .expect("hydration must return the output");
7188        let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
7189            .expect("hydrated BEEF parses");
7190
7191        assert_eq!(
7192            beef.bumps.len(),
7193            bumps_before,
7194            "asking for history must not strip the merkle proof (issue #4)"
7195        );
7196    }
7197
7198    #[tokio::test]
7199    async fn test_submit_unsupported_topic_errors() {
7200        let engine = make_engine(vec![0]);
7201        let beef = test_tagged_beef(vec!["tm_nonexistent"]);
7202
7203        let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
7204        assert!(result.is_err());
7205        assert!(matches!(
7206            result.unwrap_err(),
7207            EngineError::UnsupportedTopic(_)
7208        ));
7209    }
7210
7211    #[tokio::test]
7212    async fn test_submit_admits_outputs() {
7213        let engine = make_engine(vec![0, 1]);
7214        let beef = test_tagged_beef(vec!["tm_test"]);
7215
7216        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7217
7218        // STEAK should have tm_test with outputs 0 and 1 admitted
7219        let instructions = steak.get("tm_test").unwrap();
7220        assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
7221
7222        // Outputs should be in storage with real data
7223        let txid = TEST_TXID;
7224        let found = engine
7225            .storage()
7226            .find_output(txid, 0, Some("tm_test"), None, true)
7227            .await
7228            .unwrap()
7229            .expect("Output 0 should be in storage");
7230
7231        assert_eq!(found.txid, TEST_TXID);
7232        assert_eq!(found.satoshis, 26172); // Real value from BRC62 BEEF
7233        assert!(
7234            !found.output_script.is_empty(),
7235            "Script should be populated"
7236        );
7237        assert!(found.beef.is_some(), "BEEF should be stored");
7238        assert!(!found.spent);
7239    }
7240
7241    #[tokio::test]
7242    async fn test_submit_duplicate_is_skipped() {
7243        let engine = make_engine(vec![0]);
7244        let beef = test_tagged_beef(vec!["tm_test"]);
7245
7246        // First submit
7247        let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7248        assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
7249
7250        // Second submit — should be a dupe, no new outputs admitted
7251        let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7252        assert!(steak2["tm_test"].outputs_to_admit.is_empty());
7253    }
7254
7255    #[tokio::test]
7256    async fn test_submit_notifies_lookup_service() {
7257        let engine = make_engine(vec![0]);
7258        let beef = test_tagged_beef(vec!["tm_test"]);
7259
7260        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7261
7262        // Lookup service should have the output
7263        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7264        let answer = engine.lookup(&question, None).await.unwrap();
7265
7266        match answer {
7267            LookupAnswer::OutputList { outputs } => {
7268                assert_eq!(outputs.len(), 1);
7269            }
7270            _ => panic!("Expected OutputList"),
7271        }
7272    }
7273
7274    /// #289: `lookup_with_txids` returns the storage txid per hydrated
7275    /// output, aligned with the OutputList — the aggregated serializer
7276    /// writes it instead of re-hashing the BEEF.
7277    #[tokio::test]
7278    async fn test_lookup_with_txids_aligns_txids_with_outputs() {
7279        let engine = make_engine(vec![0]);
7280        let beef = test_tagged_beef(vec!["tm_test"]);
7281        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7282
7283        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7284        let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7285        match answer {
7286            LookupAnswer::OutputList { outputs } => {
7287                assert_eq!(outputs.len(), 1);
7288                assert_eq!(txids.len(), outputs.len(), "one txid per output");
7289                assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
7290            }
7291            _ => panic!("Expected OutputList"),
7292        }
7293    }
7294
7295    #[tokio::test]
7296    async fn proof_window_holds_an_aged_child_with_a_proven_parent() {
7297        let mut engine = make_engine(vec![0]);
7298        let storage = std::rc::Rc::new(MemoryStorage::new());
7299        engine.storage = Box::new(storage.clone());
7300        engine
7301            .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7302            .await
7303            .unwrap();
7304        assert!(beef_limits::own_proof(&test_beef(), TEST_TXID)
7305            .unwrap()
7306            .is_none());
7307        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7308        storage.advance_clock((crate::proof_window::PROOFLESS_FLAG_MS / 1000) as u64);
7309        assert_eq!(
7310            engine
7311                .lookup_with_txids(&question, None)
7312                .await
7313                .unwrap()
7314                .1
7315                .len(),
7316            1
7317        );
7318        storage.advance_clock(1);
7319        assert!(
7320            engine
7321                .lookup_with_txids(&question, None)
7322                .await
7323                .unwrap()
7324                .1
7325                .is_empty(),
7326            "an aged unproven child must be held even when its parent is proven"
7327        );
7328    }
7329
7330    #[tokio::test]
7331    async fn proof_window_peer_proof_verifies_then_restores_lookup() {
7332        use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7333        let mut engine = make_engine(vec![0]);
7334        let storage = std::rc::Rc::new(MemoryStorage::new());
7335        engine.storage = Box::new(storage.clone());
7336        engine
7337            .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7338            .await
7339            .unwrap();
7340        storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7341        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7342        let candidate = HeldOutput {
7343            txid: TEST_TXID.into(),
7344            output_index: 0,
7345            topic: "tm_test".into(),
7346        };
7347        let (_, mut remote) = budget_page_engine(1);
7348        remote.node_cost_ms = 0;
7349        remote.nodes = std::rc::Rc::new(HashMap::from([(
7350            TEST_TXID.into(),
7351            crate::types::GASPNode {
7352                graph_id: String::new(),
7353                raw_tx: String::new(),
7354                output_index: 0,
7355                proof: Some(single_leaf_bump_hex(TEST_TXID, 800_000)),
7356                tx_metadata: None,
7357                output_metadata: None,
7358                inputs: None,
7359            },
7360        )]));
7361        engine
7362            .config
7363            .sync_configuration
7364            .insert("tm_test".into(), SyncTarget::Ship);
7365        engine.ship_bootstrap_peers.insert(
7366            "tm_test".into(),
7367            vec!["https://a.example".into(), "https://b.example".into()],
7368        );
7369        engine.set_gasp_remote_factory(Box::new(remote));
7370        let progress = std::cell::Cell::new(PeerProofSummary::default());
7371        engine.chain_tracker = Some(Box::new(bsv_rs::transaction::MockChainTracker::new(
7372            800_001,
7373        )));
7374        engine
7375            .rerequest_held_proofs(std::slice::from_ref(&candidate), &progress)
7376            .await;
7377        assert_eq!(progress.get().requests, 2, "ask every topic peer");
7378        assert_eq!(progress.get().landed, 0);
7379        assert_eq!(progress.get().refused, 2);
7380        assert!(engine
7381            .lookup_with_txids(&question, None)
7382            .await
7383            .unwrap()
7384            .1
7385            .is_empty());
7386        let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7387        tracker.add_root(800_000, TEST_TXID.into());
7388        engine.chain_tracker = Some(Box::new(tracker));
7389        engine.rerequest_held_proofs(&[candidate], &progress).await;
7390        assert_eq!(progress.get().requests, 4);
7391        assert_eq!(
7392            progress.get().landed,
7393            1,
7394            "count durable proofs, not peer answers"
7395        );
7396        let (_, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7397        assert_eq!(txids, vec![TEST_TXID]);
7398        let stored = storage
7399            .find_output(TEST_TXID, 0, None, None, true)
7400            .await
7401            .unwrap()
7402            .unwrap();
7403        assert!(
7404            beef_limits::own_proof(stored.beef.as_ref().unwrap(), TEST_TXID)
7405                .unwrap()
7406                .is_some()
7407        );
7408        assert_eq!(storage.proven_at_height(TEST_TXID), Some(Some(800_000)));
7409    }
7410
7411    #[tokio::test]
7412    async fn proof_window_peer_hash_leaf_is_stored_as_own_proof() {
7413        use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7414        let mut engine = make_engine(vec![0]);
7415        let storage = std::rc::Rc::new(MemoryStorage::new());
7416        engine.storage = Box::new(storage.clone());
7417        engine
7418            .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7419            .await
7420            .unwrap();
7421        storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7422        let mut proof =
7423            bsv_rs::transaction::MerklePath::from_hex(&single_leaf_bump_hex(TEST_TXID, 800_000))
7424                .unwrap();
7425        proof.path[0][0].txid = false;
7426        let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7427        tracker.add_root(800_000, proof.compute_root(Some(TEST_TXID)).unwrap());
7428        engine.chain_tracker = Some(Box::new(tracker));
7429        let (_, mut remote) = budget_page_engine(1);
7430        remote.node_cost_ms = 0;
7431        remote.nodes = std::rc::Rc::new(HashMap::from([(
7432            TEST_TXID.into(),
7433            crate::types::GASPNode {
7434                graph_id: String::new(),
7435                raw_tx: String::new(),
7436                output_index: 0,
7437                proof: Some(proof.to_hex()),
7438                tx_metadata: None,
7439                output_metadata: None,
7440                inputs: None,
7441            },
7442        )]));
7443        engine.config.sync_configuration.insert(
7444            "tm_test".into(),
7445            SyncTarget::Peers(vec!["https://peer.example".into()]),
7446        );
7447        engine.set_gasp_remote_factory(Box::new(remote));
7448        let progress = std::cell::Cell::new(PeerProofSummary::default());
7449        engine
7450            .rerequest_held_proofs(
7451                &[HeldOutput {
7452                    txid: TEST_TXID.into(),
7453                    output_index: 0,
7454                    topic: "tm_test".into(),
7455                }],
7456                &progress,
7457            )
7458            .await;
7459        assert_eq!(progress.get().landed, 1);
7460        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7461        let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7462        assert_eq!(txids, vec![TEST_TXID]);
7463        let LookupAnswer::OutputList { outputs } = answer else {
7464            panic!("expected outputs")
7465        };
7466        assert!(
7467            beef_limits::own_proof(&outputs[0].beef, TEST_TXID)
7468                .unwrap()
7469                .is_some(),
7470            "a proven flag must have the child's own path in the served BEEF"
7471        );
7472    }
7473
7474    #[tokio::test]
7475    async fn proof_window_silent_peers_yield_at_the_request_budget() {
7476        use crate::proof_window::{HeldOutput, PeerProofSummary, PEER_PROOF_REQUEST_MS};
7477        let (mut engine, remote) = budget_page_engine(1);
7478        engine.config.sync_configuration.insert(
7479            "tm_test".into(),
7480            SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
7481        );
7482        let elapsed = remote.elapsed.clone();
7483        engine.set_peer_sync_budget(
7484            std::rc::Rc::new(move |ms| {
7485                assert_eq!(ms, PEER_PROOF_REQUEST_MS);
7486                let deadline = elapsed.get() + ms;
7487                let elapsed = elapsed.clone();
7488                Box::pin(std::future::poll_fn(move |_| {
7489                    if elapsed.get() >= deadline {
7490                        std::task::Poll::Ready(())
7491                    } else {
7492                        std::task::Poll::Pending
7493                    }
7494                }))
7495            }),
7496            30_000,
7497        );
7498        let candidate = HeldOutput {
7499            txid: remote.outputs[0].txid.clone(),
7500            output_index: 0,
7501            topic: "tm_test".into(),
7502        };
7503        engine.set_gasp_remote_factory(Box::new(remote));
7504        let progress = std::cell::Cell::new(PeerProofSummary::default());
7505        engine.rerequest_held_proofs(&[candidate], &progress).await;
7506        assert_eq!(progress.get().requests, 2);
7507        assert_eq!(progress.get().faults, 2);
7508        assert_eq!(progress.get().landed, 0);
7509    }
7510
7511    /// #291: the public sync-response page size is always bounded.
7512    #[test]
7513    fn sync_limit_clamp() {
7514        assert_eq!(
7515            Engine::clamp_sync_limit(None),
7516            Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
7517            "absent limit gets the default page, never unbounded"
7518        );
7519        assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
7520        assert_eq!(
7521            Engine::clamp_sync_limit(Some(u64::MAX)),
7522            Engine::SYNC_RESPONSE_MAX_LIMIT,
7523            "a huge caller-supplied limit is capped"
7524        );
7525    }
7526
7527    #[tokio::test]
7528    async fn test_lookup_unknown_service_errors() {
7529        let engine = make_engine(vec![]);
7530        let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
7531
7532        let result = engine.lookup(&question, None).await;
7533        assert!(result.is_err());
7534        assert!(matches!(
7535            result.unwrap_err(),
7536            EngineError::LookupServiceNotFound(_)
7537        ));
7538    }
7539
7540    #[tokio::test]
7541    async fn test_list_topic_managers() {
7542        let engine = make_engine(vec![]);
7543        let managers = engine.list_topic_managers().await;
7544
7545        assert_eq!(managers.len(), 1);
7546        assert!(managers.contains_key("tm_test"));
7547        assert_eq!(managers["tm_test"].name, "mock-tm");
7548    }
7549
7550    #[tokio::test]
7551    async fn test_list_lookup_service_providers() {
7552        let engine = make_engine(vec![]);
7553        let services = engine.list_lookup_service_providers().await;
7554
7555        assert_eq!(services.len(), 1);
7556        assert!(services.contains_key("ls_test"));
7557        assert_eq!(services["ls_test"].name, "mock-ls");
7558    }
7559
7560    #[tokio::test]
7561    async fn test_get_documentation() {
7562        let engine = make_engine(vec![]);
7563
7564        let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
7565        assert_eq!(tm_docs, "Mock topic manager");
7566
7567        let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
7568        assert_eq!(ls_docs, "Mock lookup service");
7569
7570        let missing = engine
7571            .get_documentation_for_topic_manager("tm_missing")
7572            .await;
7573        assert_eq!(missing, "No documentation found!");
7574    }
7575
7576    #[tokio::test]
7577    async fn test_provide_foreign_sync_response() {
7578        let engine = make_engine(vec![0]);
7579
7580        // Submit data
7581        let beef = test_tagged_beef(vec!["tm_test"]);
7582        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7583
7584        // Query GASP sync
7585        let request = GASPInitialRequest {
7586            version: 1,
7587            since: 0,
7588            limit: Some(100),
7589        };
7590        let response = engine
7591            .provide_foreign_sync_response(&request, "tm_test")
7592            .await
7593            .unwrap();
7594
7595        // One tx admitted with output index 0
7596        assert_eq!(response.utxo_list.len(), 1);
7597        assert_eq!(response.utxo_list[0].txid, TEST_TXID);
7598        assert_eq!(response.since, 0);
7599    }
7600
7601    #[tokio::test]
7602    async fn test_delete_utxo_deep_simple() {
7603        let engine = make_engine(vec![0]);
7604        let beef = test_tagged_beef(vec!["tm_test"]);
7605
7606        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7607
7608        let txid = TEST_TXID;
7609        let output = engine
7610            .storage()
7611            .find_output(txid, 0, Some("tm_test"), None, false)
7612            .await
7613            .unwrap()
7614            .unwrap();
7615
7616        // Delete it
7617        engine.delete_utxo_deep(&output, None).await.unwrap();
7618
7619        // Should be gone
7620        let found = engine
7621            .storage()
7622            .find_output(txid, 0, Some("tm_test"), None, false)
7623            .await
7624            .unwrap();
7625        assert!(found.is_none());
7626    }
7627
7628    #[tokio::test]
7629    async fn test_sync_config_defaults_to_ship() {
7630        let engine = make_engine(vec![]);
7631        let sync = &engine.config.sync_configuration;
7632
7633        // tm_test should default to SHIP sync
7634        assert!(sync.contains_key("tm_test"));
7635        assert!(matches!(sync["tm_test"], SyncTarget::Ship));
7636    }
7637
7638    #[tokio::test]
7639    async fn test_multiple_topics_in_single_submit() {
7640        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7641        managers.insert(
7642            "tm_alpha".to_string(),
7643            Box::new(MockTopicManager::admitting(vec![0])),
7644        );
7645        managers.insert(
7646            "tm_beta".to_string(),
7647            Box::new(MockTopicManager::admitting(vec![1])),
7648        );
7649
7650        let engine = Engine::new(
7651            managers,
7652            HashMap::new(),
7653            Box::new(MemoryStorage::new()),
7654            None,
7655            EngineConfig::default(),
7656        );
7657
7658        let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
7659
7660        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7661
7662        assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
7663        assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
7664    }
7665
7666    #[tokio::test]
7667    async fn test_evict_output_with_topic() {
7668        let engine = make_engine(vec![0]);
7669        let beef = test_tagged_beef(vec!["tm_test"]);
7670        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7671
7672        // Output should exist
7673        let found = engine
7674            .storage()
7675            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7676            .await
7677            .unwrap();
7678        assert!(found.is_some(), "Output should exist before eviction");
7679
7680        // Evict with topic
7681        engine
7682            .evict_output(TEST_TXID, 0, Some("tm_test"))
7683            .await
7684            .unwrap();
7685
7686        // Output should be gone from storage
7687        let found = engine
7688            .storage()
7689            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7690            .await
7691            .unwrap();
7692        assert!(found.is_none(), "Output should be gone after eviction");
7693
7694        // Lookup service should also have been notified (output evicted)
7695        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7696        let answer = engine.lookup(&question, None).await.unwrap();
7697        match answer {
7698            LookupAnswer::OutputList { outputs } => {
7699                assert!(
7700                    outputs.is_empty(),
7701                    "Lookup should return empty after eviction"
7702                );
7703            }
7704            _ => panic!("Expected OutputList"),
7705        }
7706    }
7707
7708    #[tokio::test]
7709    async fn test_evict_output_without_topic() {
7710        let engine = make_engine(vec![0]);
7711        let beef = test_tagged_beef(vec!["tm_test"]);
7712        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7713
7714        // Evict without topic — should find and remove across all topics
7715        engine.evict_output(TEST_TXID, 0, None).await.unwrap();
7716
7717        let found = engine
7718            .storage()
7719            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7720            .await
7721            .unwrap();
7722        assert!(
7723            found.is_none(),
7724            "Output should be gone after topic-less eviction"
7725        );
7726    }
7727
7728    #[tokio::test]
7729    async fn test_evict_nonexistent_output_is_ok() {
7730        let engine = make_engine(vec![]);
7731
7732        // Evicting something that doesn't exist should not error
7733        let result = engine
7734            .evict_output("nonexistent_txid", 99, Some("tm_test"))
7735            .await;
7736        assert!(result.is_ok(), "Evicting nonexistent output should succeed");
7737    }
7738
7739    // ── Mock Broadcaster ──────────────────────────────────────────────
7740
7741    use crate::broadcaster::Broadcaster;
7742    use std::sync::Arc;
7743
7744    type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
7745
7746    struct MockBroadcaster {
7747        calls: BroadcastCallLog,
7748    }
7749
7750    impl MockBroadcaster {
7751        fn new() -> Self {
7752            Self {
7753                calls: Arc::new(Mutex::new(Vec::new())),
7754            }
7755        }
7756
7757        #[allow(dead_code, reason = "kept for future test extension")]
7758        fn call_count(&self) -> usize {
7759            self.calls.lock().unwrap().len()
7760        }
7761
7762        #[allow(dead_code, reason = "kept for future test extension")]
7763        fn calls(&self) -> Vec<(String, Vec<String>)> {
7764            self.calls.lock().unwrap().clone()
7765        }
7766    }
7767
7768    #[async_trait(?Send)]
7769    impl Broadcaster for MockBroadcaster {
7770        async fn broadcast_to_host(
7771            &self,
7772            host_url: &str,
7773            tagged_beef: &TaggedBEEF,
7774        ) -> Result<(), String> {
7775            self.calls
7776                .lock()
7777                .unwrap()
7778                .push((host_url.to_string(), tagged_beef.topics.clone()));
7779            Ok(())
7780        }
7781    }
7782
7783    // ── Mock SHIP LookupService ─────────────────────────────────────
7784
7785    /// A mock "ls_ship" lookup service that returns pre-configured UTXOReferences
7786    /// when queried. Used to test SHIP propagation without depending on
7787    /// overlay-discovery in the engine crate's unit tests.
7788    struct MockSHIPLookupService {
7789        /// Maps topic -> list of (txid, output_index) references
7790        records: Mutex<HashMap<String, Vec<UTXOReference>>>,
7791    }
7792
7793    impl MockSHIPLookupService {
7794        fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
7795            Self {
7796                records: Mutex::new(records),
7797            }
7798        }
7799    }
7800
7801    #[async_trait(?Send)]
7802    impl LookupServiceTrait for MockSHIPLookupService {
7803        fn admission_mode(&self) -> AdmissionMode {
7804            AdmissionMode::LockingScript
7805        }
7806
7807        fn spend_notification_mode(&self) -> SpendNotificationMode {
7808            SpendNotificationMode::None
7809        }
7810
7811        async fn output_admitted_by_topic(
7812            &self,
7813            _payload: &OutputAdmittedByTopic,
7814        ) -> Result<(), LookupServiceError> {
7815            Ok(())
7816        }
7817
7818        async fn output_evicted(
7819            &self,
7820            _txid: &str,
7821            _output_index: u32,
7822        ) -> Result<(), LookupServiceError> {
7823            Ok(())
7824        }
7825
7826        async fn lookup(
7827            &self,
7828            question: &LookupQuestion,
7829        ) -> Result<LookupResult, LookupServiceError> {
7830            // Parse the topics from the query
7831            if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
7832                let records = self.records.lock().unwrap();
7833                let mut results = Vec::new();
7834                for topic_val in topics {
7835                    if let Some(topic) = topic_val.as_str() {
7836                        if let Some(refs) = records.get(topic) {
7837                            results.extend(refs.iter().cloned());
7838                        }
7839                    }
7840                }
7841                return Ok(LookupResult::OutputList(results));
7842            }
7843            Ok(LookupResult::OutputList(vec![]))
7844        }
7845
7846        async fn get_documentation(&self) -> String {
7847            "Mock SHIP lookup service".to_string()
7848        }
7849
7850        async fn get_metadata(&self) -> ServiceMetadata {
7851            ServiceMetadata {
7852                name: "mock-ls-ship".to_string(),
7853                ..Default::default()
7854            }
7855        }
7856    }
7857
7858    /// Build a minimal PushDrop locking script for a SHIP advertisement.
7859    fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
7860        use bsv_rs::script::templates::PushDrop;
7861        use bsv_rs::PublicKey;
7862
7863        let fields = vec![
7864            b"SHIP".to_vec(),
7865            vec![0x02; 33], // fake compressed pubkey bytes for identity_key field
7866            domain.as_bytes().to_vec(),
7867            topic.as_bytes().to_vec(),
7868        ];
7869
7870        // PushDrop requires a real compressed public key for the locking script
7871        // Use a well-known test key (generator point G)
7872        let pubkey = PublicKey::from_hex(
7873            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7874        )
7875        .expect("valid test pubkey");
7876
7877        let pd = PushDrop::new(pubkey, fields);
7878        pd.lock().to_binary()
7879    }
7880
7881    /// Create engine with a broadcaster and a mock SHIP lookup service.
7882    /// The `ship_domains` parameter provides (domain, topic) pairs to pre-populate.
7883    async fn make_engine_with_broadcaster(
7884        admit_indices: Vec<u32>,
7885        broadcaster: MockBroadcaster,
7886        ship_domains: Vec<(&str, &str)>,
7887        hosting_url: Option<&str>,
7888    ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
7889        let calls = broadcaster.calls.clone();
7890
7891        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7892        managers.insert(
7893            "tm_test".to_string(),
7894            Box::new(MockTopicManager::admitting(admit_indices)),
7895        );
7896
7897        let storage = Box::new(MemoryStorage::new());
7898
7899        // Build SHIP records: map each topic to its UTXO references and
7900        // store outputs in storage with PushDrop scripts so the engine can
7901        // parse the domain when broadcasting.
7902        let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
7903
7904        for (domain, topic) in &ship_domains {
7905            // Deterministic fake txid per domain+topic
7906            let fake_txid = format!("{:064x}", {
7907                let mut h: u64 = 0;
7908                for b in domain.bytes().chain(topic.bytes()) {
7909                    h = h.wrapping_mul(31).wrapping_add(u64::from(b));
7910                }
7911                h
7912            });
7913
7914            // Add to SHIP records for the mock lookup service
7915            ship_records
7916                .entry(topic.to_string())
7917                .or_default()
7918                .push(UTXOReference {
7919                    txid: fake_txid.clone(),
7920                    output_index: 0,
7921                });
7922
7923            // Build PushDrop SHIP script and store as an output in main storage
7924            let script = build_ship_pushdrop_script(domain, topic);
7925
7926            let output = Output {
7927                txid: fake_txid,
7928                output_index: 0,
7929                output_script: script,
7930                satoshis: 1,
7931                topic: "tm_ship".to_string(),
7932                spent: false,
7933                outputs_consumed: vec![],
7934                consumed_by: vec![],
7935                beef: None,
7936                block_height: None,
7937                score: None,
7938            };
7939
7940            storage.insert_output(&output).await.unwrap();
7941        }
7942
7943        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7944        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7945        lookup_services.insert(
7946            "ls_ship".to_string(),
7947            Box::new(MockSHIPLookupService::with_records(ship_records)),
7948        );
7949
7950        let config = EngineConfig {
7951            hosting_url: hosting_url.map(str::to_string),
7952            ..Default::default()
7953        };
7954
7955        let engine = Engine::with_chain_tracker(
7956            managers,
7957            lookup_services,
7958            storage,
7959            None,
7960            Some(Box::new(broadcaster)),
7961            None,
7962            config,
7963        );
7964
7965        (engine, calls)
7966    }
7967
7968    // ── Broadcaster Tests ─────────────────────────────────────────────
7969
7970    #[tokio::test]
7971    async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
7972        let broadcaster = MockBroadcaster::new();
7973        let (engine, calls) = make_engine_with_broadcaster(
7974            vec![0],
7975            broadcaster,
7976            vec![("https://peer1.example.com", "tm_test")],
7977            Some("https://self.example.com"),
7978        )
7979        .await;
7980
7981        let beef = test_tagged_beef(vec!["tm_test"]);
7982        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7983
7984        // Submit should succeed
7985        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7986
7987        // Broadcaster should have been called for peer1
7988        let recorded = calls.lock().unwrap();
7989        assert_eq!(
7990            recorded.len(),
7991            1,
7992            "Broadcaster should be called once for the one SHIP peer"
7993        );
7994        assert_eq!(recorded[0].0, "https://peer1.example.com");
7995    }
7996
7997    #[tokio::test]
7998    async fn test_broadcaster_not_called_on_historical_tx() {
7999        let broadcaster = MockBroadcaster::new();
8000        let (engine, calls) = make_engine_with_broadcaster(
8001            vec![0],
8002            broadcaster,
8003            vec![("https://peer1.example.com", "tm_test")],
8004            None,
8005        )
8006        .await;
8007
8008        let beef = test_tagged_beef(vec!["tm_test"]);
8009        let steak = engine
8010            .submit(&beef, SubmitMode::HistoricalTx)
8011            .await
8012            .unwrap();
8013
8014        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8015
8016        // Broadcaster should NOT have been called
8017        assert_eq!(
8018            calls.lock().unwrap().len(),
8019            0,
8020            "Broadcaster should not be called for historical TX"
8021        );
8022    }
8023
8024    #[tokio::test]
8025    async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
8026        let broadcaster = MockBroadcaster::new();
8027        let (engine, calls) = make_engine_with_broadcaster(
8028            vec![0],
8029            broadcaster,
8030            vec![("https://peer1.example.com", "tm_test")],
8031            None,
8032        )
8033        .await;
8034
8035        let beef = test_tagged_beef(vec!["tm_test"]);
8036        let steak = engine
8037            .submit(&beef, SubmitMode::HistoricalTxNoSpv)
8038            .await
8039            .unwrap();
8040
8041        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8042
8043        assert_eq!(
8044            calls.lock().unwrap().len(),
8045            0,
8046            "Broadcaster should not be called for historical-tx-no-spv"
8047        );
8048    }
8049
8050    #[tokio::test]
8051    async fn test_broadcaster_skips_self_hosting_url() {
8052        let broadcaster = MockBroadcaster::new();
8053        let (engine, calls) = make_engine_with_broadcaster(
8054            vec![0],
8055            broadcaster,
8056            vec![
8057                ("https://self.example.com", "tm_test"),
8058                ("https://peer2.example.com", "tm_test"),
8059            ],
8060            Some("https://self.example.com"),
8061        )
8062        .await;
8063
8064        let beef = test_tagged_beef(vec!["tm_test"]);
8065        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8066
8067        // Should only broadcast to peer2, not to self
8068        let recorded = calls.lock().unwrap();
8069        assert_eq!(
8070            recorded.len(),
8071            1,
8072            "Should broadcast to peer2 only, skipping self"
8073        );
8074        assert_eq!(recorded[0].0, "https://peer2.example.com");
8075    }
8076
8077    #[tokio::test]
8078    async fn test_broadcaster_not_called_when_no_outputs_admitted() {
8079        let broadcaster = MockBroadcaster::new();
8080        // Topic manager admits nothing
8081        let (engine, calls) = make_engine_with_broadcaster(
8082            vec![],
8083            broadcaster,
8084            vec![("https://peer1.example.com", "tm_test")],
8085            None,
8086        )
8087        .await;
8088
8089        let beef = test_tagged_beef(vec!["tm_test"]);
8090        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8091
8092        assert!(steak["tm_test"].outputs_to_admit.is_empty());
8093
8094        // No outputs admitted -> no broadcast
8095        assert_eq!(
8096            calls.lock().unwrap().len(),
8097            0,
8098            "Broadcaster should not be called when no outputs are admitted"
8099        );
8100    }
8101
8102    #[tokio::test]
8103    async fn test_broadcaster_not_called_when_no_ship_peers() {
8104        let broadcaster = MockBroadcaster::new();
8105        // No SHIP peers registered
8106        let (engine, calls) = make_engine_with_broadcaster(
8107            vec![0],
8108            broadcaster,
8109            vec![], // no SHIP peers
8110            None,
8111        )
8112        .await;
8113
8114        let beef = test_tagged_beef(vec!["tm_test"]);
8115        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8116
8117        assert_eq!(
8118            calls.lock().unwrap().len(),
8119            0,
8120            "Broadcaster should not be called when no SHIP peers exist"
8121        );
8122    }
8123
8124    // ── Configurable SpendNotification mock ─────────────────────────────
8125
8126    /// A mock lookup service whose SpendNotificationMode can be configured.
8127    /// Uses a shared Arc<Mutex<Vec<OutputSpent>>> so the caller can inspect
8128    /// captured payloads after submit().
8129    struct SpendModeLookupService {
8130        mode: SpendNotificationMode,
8131        records: Mutex<Vec<UTXOReference>>,
8132        spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
8133    }
8134
8135    impl SpendModeLookupService {
8136        fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
8137            Self {
8138                mode,
8139                records: Mutex::new(Vec::new()),
8140                spent_payloads: capture,
8141            }
8142        }
8143    }
8144
8145    #[async_trait(?Send)]
8146    impl LookupServiceTrait for SpendModeLookupService {
8147        fn admission_mode(&self) -> AdmissionMode {
8148            AdmissionMode::LockingScript
8149        }
8150
8151        fn spend_notification_mode(&self) -> SpendNotificationMode {
8152            self.mode
8153        }
8154
8155        async fn output_admitted_by_topic(
8156            &self,
8157            payload: &OutputAdmittedByTopic,
8158        ) -> Result<(), LookupServiceError> {
8159            let (txid, oi) = match payload {
8160                OutputAdmittedByTopic::LockingScript {
8161                    txid, output_index, ..
8162                } => (txid.clone(), *output_index),
8163                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
8164                    ("whole".into(), *output_index)
8165                }
8166            };
8167            self.records.lock().unwrap().push(UTXOReference {
8168                txid,
8169                output_index: oi,
8170            });
8171            Ok(())
8172        }
8173
8174        async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
8175            self.spent_payloads.lock().unwrap().push(payload.clone());
8176            Ok(())
8177        }
8178
8179        async fn output_evicted(
8180            &self,
8181            txid: &str,
8182            output_index: u32,
8183        ) -> Result<(), LookupServiceError> {
8184            self.records
8185                .lock()
8186                .unwrap()
8187                .retain(|r| !(r.txid == txid && r.output_index == output_index));
8188            Ok(())
8189        }
8190
8191        async fn lookup(
8192            &self,
8193            _question: &LookupQuestion,
8194        ) -> Result<LookupResult, LookupServiceError> {
8195            Ok(LookupResult::OutputList(
8196                self.records.lock().unwrap().clone(),
8197            ))
8198        }
8199
8200        async fn get_documentation(&self) -> String {
8201            "SpendMode mock lookup service".to_string()
8202        }
8203
8204        async fn get_metadata(&self) -> ServiceMetadata {
8205            ServiceMetadata {
8206                name: "spend-mode-ls".to_string(),
8207                ..Default::default()
8208            }
8209        }
8210    }
8211
8212    /// TXID of the input's source transaction in TEST_BEEF_HEX (the "previous coin").
8213    const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
8214
8215    /// Build an engine with a pre-populated previous output and a SpendModeLookupService.
8216    /// Returns the engine and the shared capture vec for inspecting OutputSpent payloads.
8217    async fn make_spend_mode_engine(
8218        mode: SpendNotificationMode,
8219    ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
8220        let storage = MemoryStorage::new();
8221
8222        // Pre-populate storage with the previous output that TEST_BEEF_HEX's input spends.
8223        let prev_output = Output {
8224            txid: PREVIOUS_TXID.to_string(),
8225            output_index: 0,
8226            output_script: vec![0x76, 0xa9],
8227            satoshis: 26174,
8228            topic: "tm_test".to_string(),
8229            spent: false,
8230            outputs_consumed: vec![],
8231            consumed_by: vec![],
8232            beef: Some(test_beef()),
8233            block_height: None,
8234            score: Some(1000.0),
8235        };
8236        storage.insert_output(&prev_output).await.unwrap();
8237
8238        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8239        managers.insert(
8240            "tm_test".to_string(),
8241            Box::new(MockTopicManager::admitting(vec![0])),
8242        );
8243
8244        let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
8245        let ls = SpendModeLookupService::with_mode(mode, capture.clone());
8246        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8247        lookup_services.insert("ls_test".to_string(), Box::new(ls));
8248
8249        let engine = Engine::new(
8250            managers,
8251            lookup_services,
8252            Box::new(storage),
8253            None,
8254            EngineConfig::default(),
8255        );
8256
8257        (engine, capture)
8258    }
8259
8260    #[tokio::test]
8261    async fn test_spend_notification_mode_script() {
8262        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
8263
8264        let beef = test_tagged_beef(vec!["tm_test"]);
8265        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8266
8267        // The new output should be admitted
8268        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8269
8270        // The lookup service should have received a Script spend notification
8271        let payloads = capture.lock().unwrap().clone();
8272        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8273
8274        match &payloads[0] {
8275            OutputSpent::Script {
8276                txid,
8277                output_index,
8278                topic,
8279                spending_txid,
8280                input_index,
8281                unlocking_script,
8282                sequence_number,
8283                ..
8284            } => {
8285                assert_eq!(txid, PREVIOUS_TXID);
8286                assert_eq!(*output_index, 0);
8287                assert_eq!(topic, "tm_test");
8288                assert_eq!(spending_txid, TEST_TXID);
8289                // The BEEF has a single input at index 0 that spends PREVIOUS_TXID:0
8290                assert_eq!(*input_index, 0);
8291                // The unlocking script should be non-empty (it's a P2PKH scriptSig)
8292                assert!(
8293                    !unlocking_script.is_empty(),
8294                    "Unlocking script should be non-empty"
8295                );
8296                // Standard final sequence number
8297                assert_eq!(*sequence_number, 0xffff_ffff);
8298            }
8299            other => panic!("Expected OutputSpent::Script, got: {other:?}"),
8300        }
8301    }
8302
8303    #[tokio::test]
8304    async fn test_spend_notification_mode_whole_tx() {
8305        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
8306
8307        let beef = test_tagged_beef(vec!["tm_test"]);
8308        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8309
8310        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8311
8312        let payloads = capture.lock().unwrap().clone();
8313        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8314
8315        match &payloads[0] {
8316            OutputSpent::WholeTx {
8317                txid,
8318                output_index,
8319                topic,
8320                spending_atomic_beef,
8321                ..
8322            } => {
8323                assert_eq!(txid, PREVIOUS_TXID);
8324                assert_eq!(*output_index, 0);
8325                assert_eq!(topic, "tm_test");
8326                // The spending BEEF is the entire BEEF we submitted, NAMED
8327                // (atomic prefix) for the lookup service's own re-parse.
8328                let mut named =
8329                    bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
8330                assert!(named.is_atomic());
8331                let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
8332                assert_eq!(
8333                    named.txs.len(),
8334                    submitted.txs.len(),
8335                    "the whole body, never pruned"
8336                );
8337                let tip = crate::subject::subject_txid_of(&mut named).unwrap();
8338                assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
8339            }
8340            other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
8341        }
8342    }
8343
8344    #[tokio::test]
8345    async fn test_spend_notification_mode_txid() {
8346        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
8347
8348        let beef = test_tagged_beef(vec!["tm_test"]);
8349        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8350
8351        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8352
8353        let payloads = capture.lock().unwrap().clone();
8354        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8355
8356        match &payloads[0] {
8357            OutputSpent::Txid {
8358                txid,
8359                output_index,
8360                topic,
8361                spending_txid,
8362            } => {
8363                assert_eq!(txid, PREVIOUS_TXID);
8364                assert_eq!(*output_index, 0);
8365                assert_eq!(topic, "tm_test");
8366                assert_eq!(spending_txid, TEST_TXID);
8367            }
8368            other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
8369        }
8370    }
8371
8372    #[tokio::test]
8373    async fn test_spend_notification_mode_none() {
8374        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
8375
8376        let beef = test_tagged_beef(vec!["tm_test"]);
8377        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8378
8379        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8380
8381        let payloads = capture.lock().unwrap().clone();
8382        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8383
8384        match &payloads[0] {
8385            OutputSpent::None {
8386                txid,
8387                output_index,
8388                topic,
8389            } => {
8390                assert_eq!(txid, PREVIOUS_TXID);
8391                assert_eq!(*output_index, 0);
8392                assert_eq!(topic, "tm_test");
8393            }
8394            other => panic!("Expected OutputSpent::None, got: {other:?}"),
8395        }
8396    }
8397
8398    // ── GASP Sync Tests ───────────────────────────────────────────────
8399
8400    #[tokio::test]
8401    async fn test_start_gasp_sync_empty_config() {
8402        let engine = Engine::new(
8403            HashMap::new(),
8404            HashMap::new(),
8405            Box::new(MemoryStorage::new()),
8406            None,
8407            EngineConfig {
8408                sync_configuration: HashMap::new(),
8409                ..Default::default()
8410            },
8411        );
8412
8413        let result = engine.start_gasp_sync().await.unwrap();
8414        assert!(
8415            result.topics_synced.is_empty(),
8416            "No topics should be synced with empty config"
8417        );
8418    }
8419
8420    #[tokio::test]
8421    async fn test_start_gasp_sync_disabled_topic_skipped() {
8422        let mut sync_config: SyncConfiguration = HashMap::new();
8423        sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
8424
8425        let engine = Engine::new(
8426            HashMap::new(),
8427            HashMap::new(),
8428            Box::new(MemoryStorage::new()),
8429            None,
8430            EngineConfig {
8431                sync_configuration: sync_config,
8432                ..Default::default()
8433            },
8434        );
8435
8436        let result = engine.start_gasp_sync().await.unwrap();
8437        assert!(
8438            result.topics_synced.is_empty(),
8439            "Disabled topics should not appear in results"
8440        );
8441    }
8442
8443    #[tokio::test]
8444    async fn test_start_gasp_sync_peers_config() {
8445        let mut sync_config: SyncConfiguration = HashMap::new();
8446        sync_config.insert(
8447            "tm_test".to_string(),
8448            SyncTarget::Peers(vec![
8449                "https://peer1.example.com".to_string(),
8450                "https://peer2.example.com".to_string(),
8451            ]),
8452        );
8453
8454        let engine = Engine::new(
8455            HashMap::new(),
8456            HashMap::new(),
8457            Box::new(MemoryStorage::new()),
8458            None,
8459            EngineConfig {
8460                sync_configuration: sync_config,
8461                ..Default::default()
8462            },
8463        );
8464
8465        let result = engine.start_gasp_sync().await.unwrap();
8466        assert_eq!(result.topics_synced.len(), 1);
8467
8468        let topic_result = &result.topics_synced["tm_test"];
8469        assert_eq!(topic_result.peers.len(), 2);
8470        assert_eq!(topic_result.sync_type, "peers");
8471        assert!(topic_result.errors.is_empty());
8472    }
8473
8474    #[tokio::test]
8475    async fn test_start_gasp_sync_peers_filters_self() {
8476        let mut sync_config: SyncConfiguration = HashMap::new();
8477        sync_config.insert(
8478            "tm_test".to_string(),
8479            SyncTarget::Peers(vec![
8480                "https://self.example.com".to_string(),
8481                "https://peer2.example.com".to_string(),
8482            ]),
8483        );
8484
8485        let engine = Engine::new(
8486            HashMap::new(),
8487            HashMap::new(),
8488            Box::new(MemoryStorage::new()),
8489            None,
8490            EngineConfig {
8491                hosting_url: Some("https://self.example.com".to_string()),
8492                sync_configuration: sync_config,
8493                ..Default::default()
8494            },
8495        );
8496
8497        let result = engine.start_gasp_sync().await.unwrap();
8498        let topic_result = &result.topics_synced["tm_test"];
8499        assert_eq!(
8500            topic_result.peers.len(),
8501            1,
8502            "Self URL should be filtered out"
8503        );
8504        assert_eq!(topic_result.peers[0], "https://peer2.example.com");
8505    }
8506
8507    #[tokio::test]
8508    async fn load_0112_quiet_peer_costs_no_request_during_backoff() {
8509        let calls = std::rc::Rc::default();
8510        let store = std::rc::Rc::new(MemoryStorage::new());
8511        let mut engine = Engine::new(
8512            HashMap::new(),
8513            HashMap::new(),
8514            Box::new(store.clone()),
8515            None,
8516            EngineConfig {
8517                sync_configuration: HashMap::from([(
8518                    "tm_test".into(),
8519                    SyncTarget::Peers(vec!["https://quiet.example".into()]),
8520                )]),
8521                ..Default::default()
8522            },
8523        );
8524        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8525            calls: std::rc::Rc::clone(&calls),
8526        }));
8527        for _ in 0..4 {
8528            engine.start_gasp_sync().await.unwrap();
8529        }
8530        assert_eq!(calls.borrow().len(), 3);
8531        assert_eq!(
8532            store
8533                .get_peer_sync_health("quiet.example", "tm_test")
8534                .await
8535                .unwrap()
8536                .consecutive_failures,
8537            0
8538        );
8539        store.advance_clock(60);
8540        engine.start_gasp_sync().await.unwrap();
8541        assert_eq!(calls.borrow().len(), 4);
8542        engine.start_gasp_sync().await.unwrap();
8543        assert_eq!(calls.borrow().len(), 4);
8544        engine
8545            .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8546                topics: Some(vec!["tm_test".into()]),
8547                peers: None,
8548            })
8549            .await
8550            .unwrap();
8551        assert_eq!(calls.borrow().len(), 5);
8552        engine.start_gasp_sync().await.unwrap();
8553        assert_eq!(
8554            calls.borrow().len(),
8555            6,
8556            "explicit selection reset the quiet streak"
8557        );
8558    }
8559
8560    #[tokio::test]
8561    async fn load_0112_yield_and_advertisement_reset_cadence() {
8562        let (mut engine, remote) = budget_page_engine(1);
8563        engine.set_gasp_remote_factory(Box::new(remote));
8564        engine
8565            .storage
8566            .set_peer_cadence(
8567                "budget.example",
8568                "tm_test",
8569                &crate::sync_load::PeerCadence {
8570                    quiet_syncs: 8,
8571                    retry_after_secs: 0,
8572                    advertisement: String::new(),
8573                },
8574            )
8575            .await
8576            .unwrap();
8577        let result = engine.start_gasp_sync().await.unwrap();
8578        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
8579        assert_eq!(
8580            engine
8581                .storage
8582                .get_peer_cadence("budget.example", "tm_test")
8583                .await
8584                .unwrap()
8585                .quiet_syncs,
8586            0
8587        );
8588        engine
8589            .storage
8590            .set_peer_cadence(
8591                "budget.example",
8592                "tm_test",
8593                &crate::sync_load::PeerCadence {
8594                    quiet_syncs: 8,
8595                    retry_after_secs: 3_600,
8596                    advertisement: "previous-advertisement".into(),
8597                },
8598            )
8599            .await
8600            .unwrap();
8601        let result = engine.start_gasp_sync().await.unwrap();
8602        assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8603        assert!(
8604            engine
8605                .storage
8606                .get_peer_cadence("budget.example", "tm_test")
8607                .await
8608                .unwrap()
8609                .quiet_syncs
8610                <= 1
8611        );
8612    }
8613
8614    #[tokio::test]
8615    async fn load_0112_an_admitted_slap_change_wakes_its_peer() {
8616        let (mut engine, mut remote) = budget_page_engine(0);
8617        remote.node_cost_ms = 0;
8618        engine.set_gasp_remote_factory(Box::new(remote.clone()));
8619        for _ in 0..3 {
8620            engine.start_gasp_sync().await.unwrap();
8621        }
8622        let skipped = engine.start_gasp_sync().await.unwrap();
8623        assert_eq!(
8624            skipped.topics_synced["tm_test"].peers_skipped[0].reason,
8625            "no_yield"
8626        );
8627        let count = remote.requests.borrow().len();
8628        let ad = discovery_output("SLAP", "https://budget.example", "ls_ship");
8629        let tx = beef_limits::transaction_from_beef(&ad.beef, None, &beef_limits::PEER_BEEF_LIMITS)
8630            .unwrap();
8631        engine
8632            .storage
8633            .insert_output(&Output {
8634                txid: tx.id(),
8635                output_index: 0,
8636                output_script: tx.outputs[0].locking_script.to_binary(),
8637                satoshis: 1,
8638                topic: "tm_slap".into(),
8639                spent: false,
8640                outputs_consumed: vec![],
8641                consumed_by: vec![],
8642                beef: None,
8643                block_height: None,
8644                score: Some(1.0),
8645            })
8646            .await
8647            .unwrap();
8648        let result = engine.start_gasp_sync().await.unwrap();
8649        assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8650        assert_eq!(remote.requests.borrow().len(), count + 1);
8651    }
8652
8653    #[tokio::test]
8654    async fn load_0112_filter_selects_only_named_topic_and_peer() {
8655        let calls = std::rc::Rc::default();
8656        let mut engine = Engine::new(
8657            HashMap::new(),
8658            HashMap::new(),
8659            Box::new(MemoryStorage::new()),
8660            None,
8661            EngineConfig {
8662                sync_configuration: ["tm_alpha", "tm_beta"]
8663                    .into_iter()
8664                    .map(|t| {
8665                        (
8666                            t.to_string(),
8667                            SyncTarget::Peers(vec![
8668                                "https://a.example".into(),
8669                                "https://b.example".into(),
8670                            ]),
8671                        )
8672                    })
8673                    .collect(),
8674                ..Default::default()
8675            },
8676        );
8677        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8678            calls: std::rc::Rc::clone(&calls),
8679        }));
8680        let filter = crate::sync_load::SyncFilter::parse(
8681            r#"{"topics":["tm_beta"],"peers":["https://b.example"]}"#,
8682        )
8683        .unwrap();
8684        let result = engine.start_gasp_sync_filtered(&filter).await.unwrap();
8685        assert_eq!(
8686            *calls.borrow(),
8687            vec![("https://b.example".into(), "tm_beta".into())]
8688        );
8689        assert_eq!(result.topics_synced.len(), 1);
8690        calls.borrow_mut().clear();
8691        engine.start_gasp_sync().await.unwrap();
8692        assert_eq!(calls.borrow().len(), 4);
8693        calls.borrow_mut().clear();
8694        engine
8695            .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8696                topics: Some(vec![]),
8697                peers: None,
8698            })
8699            .await
8700            .unwrap();
8701        assert!(calls.borrow().is_empty());
8702        assert!(crate::sync_load::SyncFilter::parse(r#"{"peers":"bad"}"#).is_err());
8703        assert!(crate::sync_load::SyncFilter::parse("null").is_err());
8704        assert_eq!(
8705            crate::sync_load::SyncFilter::parse("  ").unwrap(),
8706            crate::sync_load::SyncFilter::default()
8707        );
8708    }
8709
8710    #[derive(Clone, Default)]
8711    struct MockShipLookupClient {
8712        records: HashMap<(String, String), Vec<OutputListItem>>,
8713        failures: HashSet<String>,
8714        calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
8715    }
8716
8717    struct RecordingDiscoverySyncFactory {
8718        calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
8719    }
8720
8721    impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
8722        fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8723            self.calls
8724                .borrow_mut()
8725                .push((peer.to_string(), topic.to_string()));
8726            Box::new(MockSyncRemote { utxos: Vec::new() })
8727        }
8728    }
8729
8730    #[async_trait(?Send)]
8731    impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
8732        async fn lookup(
8733            &self,
8734            host: &str,
8735            question: &LookupQuestion,
8736        ) -> Result<Vec<OutputListItem>, String> {
8737            self.calls
8738                .borrow_mut()
8739                .push((host.to_string(), question.clone()));
8740            if self.failures.contains(host) {
8741                return Err("mock tracker unavailable".to_string());
8742            }
8743            Ok(self
8744                .records
8745                .get(&(host.to_string(), question.service.clone()))
8746                .cloned()
8747                .unwrap_or_default())
8748        }
8749    }
8750
8751    fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
8752        use bsv_rs::script::templates::PushDrop;
8753        let pubkey = bsv_rs::PublicKey::from_hex(
8754            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
8755        )
8756        .unwrap();
8757        let script = PushDrop::new(
8758            pubkey,
8759            vec![
8760                protocol.as_bytes().to_vec(),
8761                vec![0x02; 33],
8762                domain.as_bytes().to_vec(),
8763                scope.as_bytes().to_vec(),
8764            ],
8765        )
8766        .lock();
8767        let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
8768        tx.outputs[0].locking_script = script;
8769        tx.merkle_path = None;
8770        tx.invalidate_caches();
8771        let beef = tx.to_atomic_beef(true).unwrap();
8772        let parsed =
8773            beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
8774                .unwrap();
8775        assert_eq!(
8776            parsed.outputs[0].locking_script,
8777            tx.outputs[0].locking_script
8778        );
8779        OutputListItem {
8780            beef,
8781            output_index: 0,
8782            context: None,
8783        }
8784    }
8785
8786    #[tokio::test]
8787    async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
8788        let (mut engine, _) = make_engine_with_broadcaster(
8789            vec![0],
8790            MockBroadcaster::new(),
8791            vec![
8792                ("https://local.example", "tm_test"),
8793                ("https://shared.example/?local", "tm_test"),
8794            ],
8795            Some("https://self.example"),
8796        )
8797        .await;
8798        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8799        engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8800        let mut client = MockShipLookupClient::default();
8801        client.records.insert(
8802            ("https://tracker.example".to_string(), "ls_ship".to_string()),
8803            vec![
8804                discovery_output("SHIP", "https://direct.example", "tm_test"),
8805                discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
8806                discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
8807                discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
8808                discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
8809                OutputListItem {
8810                    beef: vec![1, 2, 3],
8811                    output_index: 0,
8812                    context: None,
8813                },
8814            ],
8815        );
8816        client.records.insert(
8817            ("https://tracker.example".to_string(), "ls_slap".to_string()),
8818            vec![
8819                discovery_output("SLAP", "https://lookup.example", "ls_ship"),
8820                discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
8821                discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
8822                discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
8823                discovery_output("SLAP", "https://other-service.example", "ls_other"),
8824            ],
8825        );
8826        client.records.insert(
8827            ("https://lookup.example".to_string(), "ls_ship".to_string()),
8828            vec![
8829                discovery_output("SHIP", "https://indirect.example", "tm_test"),
8830                discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
8831            ],
8832        );
8833        let calls = client.calls.clone();
8834        engine.set_ship_lookup_client(Box::new(client));
8835        let result = engine.start_gasp_sync().await.unwrap();
8836        assert_eq!(
8837            result.topics_synced["tm_test"].peers,
8838            vec![
8839                "https://bootstrap.example",
8840                "https://direct.example",
8841                "https://indirect.example",
8842                "https://local.example",
8843                "https://shared.example",
8844            ]
8845        );
8846        let calls = calls.borrow();
8847        assert_eq!(calls.len(), 3);
8848        for (_, question) in calls.iter() {
8849            let expected = if question.service == "ls_slap" {
8850                serde_json::json!({"service": "ls_ship"})
8851            } else {
8852                serde_json::json!({"topics": ["tm_test"]})
8853            };
8854            assert_eq!(question.query, expected);
8855        }
8856    }
8857
8858    #[tokio::test]
8859    async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
8860        let (mut engine, _) =
8861            make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
8862        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8863        let mut client = MockShipLookupClient::default();
8864        let outputs = (0..9)
8865            .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
8866            .collect();
8867        client.records.insert(
8868            ("https://tracker.example".to_string(), "ls_ship".to_string()),
8869            outputs,
8870        );
8871        engine.set_ship_lookup_client(Box::new(client));
8872        let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8873        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8874            calls: sync_calls.clone(),
8875        }));
8876        let result = engine.start_gasp_sync().await.unwrap();
8877        let topic = &result.topics_synced["tm_test"];
8878        assert_eq!(topic.sync_type, "ship");
8879        assert_eq!(
8880            topic.peers.len(),
8881            9,
8882            "the lookup-host cap must not truncate sync peers"
8883        );
8884        assert!(topic.errors.is_empty());
8885        assert_eq!(sync_calls.borrow().len(), 9);
8886        for peer in &topic.peers {
8887            assert!(sync_calls
8888                .borrow()
8889                .contains(&(peer.clone(), "tm_test".to_string())));
8890        }
8891    }
8892
8893    #[tokio::test]
8894    async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
8895        let mut engine = make_engine(vec![]);
8896        engine.config.slap_trackers = vec![
8897            "https://dead.example".to_string(),
8898            "https://live.example".to_string(),
8899        ];
8900        let mut client = MockShipLookupClient::default();
8901        client.failures.insert("https://dead.example".to_string());
8902        client.records.insert(
8903            ("https://live.example".to_string(), "ls_ship".to_string()),
8904            vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
8905        );
8906        engine.set_ship_lookup_client(Box::new(client));
8907        let result = engine.start_gasp_sync().await.unwrap();
8908        assert_eq!(
8909            result.topics_synced["tm_test"].peers,
8910            vec!["https://peer.example"]
8911        );
8912    }
8913
8914    #[tokio::test]
8915    async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
8916        use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
8917        let mut engine = make_engine(vec![]);
8918        engine
8919            .config
8920            .sync_configuration
8921            .insert("tm_other".to_string(), SyncTarget::Ship);
8922        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8923        let mut client = MockShipLookupClient::default();
8924        let mut providers = Vec::new();
8925        for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
8926            let host = format!("https://lookup{n}.example");
8927            providers.push(discovery_output("SLAP", &host, "ls_ship"));
8928            client.records.insert(
8929                (host, "ls_ship".to_string()),
8930                vec![
8931                    discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
8932                    discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
8933                ],
8934            );
8935        }
8936        client.records.insert(
8937            ("https://tracker.example".to_string(), "ls_slap".to_string()),
8938            providers,
8939        );
8940        let calls = client.calls.clone();
8941        engine.set_ship_lookup_client(Box::new(client));
8942        let result = engine.start_gasp_sync().await.unwrap();
8943        assert_eq!(
8944            result.topics_synced["tm_test"].peers.len(),
8945            SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8946        );
8947        assert_eq!(
8948            result.topics_synced["tm_other"].peers.len(),
8949            SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8950        );
8951        let calls = calls.borrow();
8952        assert_eq!(
8953            calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
8954            1
8955        );
8956        let providers: HashSet<_> = calls
8957            .iter()
8958            .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
8959            .map(|(h, _)| h)
8960            .collect();
8961        assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
8962        assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
8963    }
8964
8965    #[tokio::test]
8966    async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
8967        let mut engine = make_engine(vec![]);
8968        engine.config.hosting_url = Some("https://self.example".to_string());
8969        engine.config.slap_trackers = vec!["https://dead.example".to_string()];
8970        let mut client = MockShipLookupClient::default();
8971        client.failures.insert("https://dead.example".to_string());
8972        engine.set_ship_lookup_client(Box::new(client));
8973        engine.set_ship_bootstrap_peers(
8974            "tm_test",
8975            vec![
8976                "https://bootstrap.example".to_string(),
8977                "https://BOOTSTRAP.example.:443/?x".to_string(),
8978                "http://SELF.example.:8443/path".to_string(),
8979            ],
8980        );
8981        let result = engine.start_gasp_sync().await.unwrap();
8982        assert_eq!(
8983            result.topics_synced["tm_test"].peers,
8984            vec!["https://bootstrap.example"]
8985        );
8986    }
8987
8988    #[tokio::test]
8989    async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
8990        let mut engine = make_engine(vec![]);
8991        for topic in ["tm_ship", "tm_slap"] {
8992            engine.config.sync_configuration.insert(
8993                topic.to_string(),
8994                SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
8995            );
8996        }
8997        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8998        let mut client = MockShipLookupClient::default();
8999        client.records.insert(
9000            ("https://tracker.example".to_string(), "ls_ship".to_string()),
9001            vec![discovery_output(
9002                "SHIP",
9003                "https://discovered.example",
9004                "tm_test",
9005            )],
9006        );
9007        let lookups = client.calls.clone();
9008        engine.set_ship_lookup_client(Box::new(client));
9009        engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
9010        let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
9011        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
9012            calls: calls.clone(),
9013        }));
9014        engine.start_gasp_sync().await.unwrap();
9015        assert_eq!(
9016            calls
9017                .borrow()
9018                .iter()
9019                .map(|(_, topic)| topic.as_str())
9020                .collect::<Vec<_>>(),
9021            vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
9022        );
9023        assert_eq!(lookups.borrow().len(), 2);
9024    }
9025
9026    #[tokio::test]
9027    async fn test_start_gasp_sync_ship_discovers_peers() {
9028        let broadcaster = MockBroadcaster::new();
9029        let (engine, _calls) = make_engine_with_broadcaster(
9030            vec![0],
9031            broadcaster,
9032            vec![
9033                ("https://peer1.example.com", "tm_test"),
9034                ("https://peer2.example.com", "tm_test"),
9035            ],
9036            Some("https://self.example.com"),
9037        )
9038        .await;
9039
9040        let result = engine.start_gasp_sync().await.unwrap();
9041        assert!(
9042            result.topics_synced.contains_key("tm_test"),
9043            "tm_test should be in sync results"
9044        );
9045
9046        let topic_result = &result.topics_synced["tm_test"];
9047        assert_eq!(topic_result.sync_type, "ship");
9048        assert_eq!(
9049            topic_result.peers.len(),
9050            2,
9051            "Should discover both SHIP peers"
9052        );
9053        assert!(topic_result
9054            .peers
9055            .contains(&"https://peer1.example.com".to_string()));
9056        assert!(topic_result
9057            .peers
9058            .contains(&"https://peer2.example.com".to_string()));
9059    }
9060
9061    #[tokio::test]
9062    async fn test_start_gasp_sync_ship_filters_self() {
9063        let broadcaster = MockBroadcaster::new();
9064        let (engine, _calls) = make_engine_with_broadcaster(
9065            vec![0],
9066            broadcaster,
9067            vec![
9068                ("https://self.example.com", "tm_test"),
9069                ("https://peer2.example.com", "tm_test"),
9070            ],
9071            Some("https://self.example.com"),
9072        )
9073        .await;
9074
9075        let result = engine.start_gasp_sync().await.unwrap();
9076        let topic_result = &result.topics_synced["tm_test"];
9077        assert_eq!(
9078            topic_result.peers.len(),
9079            1,
9080            "Self URL should be filtered out from SHIP discovery"
9081        );
9082        assert_eq!(topic_result.peers[0], "https://peer2.example.com");
9083    }
9084
9085    #[tokio::test]
9086    async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
9087        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9088        managers.insert(
9089            "tm_custom".to_string(),
9090            Box::new(MockTopicManager::admitting(vec![])),
9091        );
9092
9093        let mut sync_config: SyncConfiguration = HashMap::new();
9094        sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
9095
9096        let engine = Engine::new(
9097            managers,
9098            HashMap::new(),
9099            Box::new(MemoryStorage::new()),
9100            None,
9101            EngineConfig {
9102                sync_configuration: sync_config,
9103                ..Default::default()
9104            },
9105        );
9106
9107        let result = engine.start_gasp_sync().await.unwrap();
9108        let topic_result = &result.topics_synced["tm_custom"];
9109        assert!(
9110            topic_result.peers.is_empty(),
9111            "Without ls_ship, SHIP discovery should return no peers"
9112        );
9113    }
9114
9115    #[tokio::test]
9116    async fn test_start_gasp_sync_mixed_config() {
9117        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9118        managers.insert(
9119            "tm_peers".to_string(),
9120            Box::new(MockTopicManager::admitting(vec![])),
9121        );
9122        managers.insert(
9123            "tm_disabled".to_string(),
9124            Box::new(MockTopicManager::admitting(vec![])),
9125        );
9126        managers.insert(
9127            "tm_ship_topic".to_string(),
9128            Box::new(MockTopicManager::admitting(vec![])),
9129        );
9130
9131        let mut sync_config: SyncConfiguration = HashMap::new();
9132        sync_config.insert(
9133            "tm_peers".to_string(),
9134            SyncTarget::Peers(vec!["https://peer.com".to_string()]),
9135        );
9136        sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
9137        sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
9138
9139        let engine = Engine::new(
9140            managers,
9141            HashMap::new(),
9142            Box::new(MemoryStorage::new()),
9143            None,
9144            EngineConfig {
9145                sync_configuration: sync_config,
9146                ..Default::default()
9147            },
9148        );
9149
9150        let result = engine.start_gasp_sync().await.unwrap();
9151        assert_eq!(result.topics_synced.len(), 2);
9152        assert!(result.topics_synced.contains_key("tm_peers"));
9153        assert!(result.topics_synced.contains_key("tm_ship_topic"));
9154        assert!(!result.topics_synced.contains_key("tm_disabled"));
9155
9156        assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
9157        assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
9158        assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
9159    }
9160
9161    #[tokio::test]
9162    async fn test_gasp_sync_result_serialization() {
9163        let mut topics_synced = HashMap::new();
9164        topics_synced.insert(
9165            "tm_test".to_string(),
9166            super::TopicSyncResult {
9167                cost: crate::meter::SyncCost::default(),
9168                root_bounds: Vec::new(),
9169                peers_skipped: Vec::new(),
9170                peers_filtered: Vec::new(),
9171                roots_proven: 0,
9172                roots_unproven: 0,
9173                cursor_held_by: Vec::new(),
9174                expired_refused: 0,
9175                peers: vec!["https://peer.com".to_string()],
9176                sync_type: "ship".to_string(),
9177                errors: vec![],
9178                pruned_inputs: 0,
9179                discarded_graphs: 0,
9180                anchor_walk_held_graphs: 0,
9181                finalized_graphs: 0,
9182                deadline_dropped_graphs: 0,
9183                cursor_moves: Vec::new(),
9184                deferred_graphs: 0,
9185                resumed_graphs: 0,
9186                converged_graphs: 0,
9187                dropped_graphs: Vec::new(),
9188                stalled_graphs: 0,
9189                held_back_graphs: 0,
9190                backed_off_graphs: 0,
9191                waiting_graphs: 0,
9192                idle_counted: 0,
9193                peer_results: HashMap::new(),
9194                dead_graphs: 0,
9195                expired_swept: 0,
9196                memo_folded: 0,
9197                unanchorable_graphs: 0,
9198                unanchorable_cleared: 0,
9199                absent_struck: 0,
9200                route_faults: Vec::new(),
9201                strikes_refused: 0,
9202                faulted_roots: Vec::new(),
9203            },
9204        );
9205
9206        let result = super::GASPSyncResult {
9207            cost: crate::meter::SyncCost::default(),
9208            topics_synced,
9209            filter: crate::sync_load::SyncFilter::default(),
9210            topics_filtered: Vec::new(),
9211        };
9212        let json = serde_json::to_string(&result).unwrap();
9213        let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
9214
9215        assert_eq!(back.topics_synced.len(), 1);
9216        assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
9217        assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
9218    }
9219
9220    // ── GASP Sync With Factory Tests ─────────────────────────────────
9221
9222    /// Mock GASPRemote for factory tests
9223    struct MockSyncRemote {
9224        utxos: Vec<crate::types::GASPOutput>,
9225    }
9226
9227    #[async_trait(?Send)]
9228    impl crate::gasp::GASPRemote for MockSyncRemote {
9229        async fn get_initial_response(
9230            &self,
9231            request: &crate::types::GASPInitialRequest,
9232        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9233            let utxos: Vec<crate::types::GASPOutput> = self
9234                .utxos
9235                .iter()
9236                .filter(|u| u.score as u64 >= request.since)
9237                .cloned()
9238                .collect();
9239            Ok(crate::types::GASPInitialResponse {
9240                utxo_list: utxos,
9241                since: request.since,
9242            })
9243        }
9244        async fn get_initial_reply(
9245            &self,
9246            _: &crate::types::GASPInitialResponse,
9247        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9248            Ok(crate::types::GASPInitialReply {
9249                utxo_list: Vec::new(),
9250            })
9251        }
9252        async fn request_node(
9253            &self,
9254            graph_id: &str,
9255            txid: &str,
9256            output_index: u32,
9257            _: bool,
9258        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9259            Ok(crate::types::GASPNode {
9260                graph_id: graph_id.to_string(),
9261                raw_tx: format!("rawtx_{txid}"),
9262                output_index,
9263                proof: None,
9264                tx_metadata: None,
9265                output_metadata: None,
9266                inputs: None,
9267            })
9268        }
9269        async fn submit_node(
9270            &self,
9271            _: &crate::types::GASPNode,
9272        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9273            Ok(None)
9274        }
9275    }
9276
9277    /// Mock factory that creates MockSyncRemote instances
9278    struct MockGASPRemoteFactory;
9279
9280    #[derive(Clone)]
9281    struct BudgetPageRemote {
9282        outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
9283        nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
9284        elapsed: std::rc::Rc<Cell<u64>>,
9285        requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
9286        clamp: Option<usize>,
9287        node_cost_ms: u64,
9288        /// One request of this txid costs this much more, once.
9289        slow_once: std::rc::Rc<std::cell::RefCell<Option<(String, u64)>>>,
9290        /// Node requests answered, by txid.
9291        asked: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
9292        /// Txids answered with a transport fault (a 503), not `NodeNotFound`.
9293        faults_for: std::rc::Rc<std::cell::RefCell<std::collections::HashSet<String>>>,
9294    }
9295
9296    #[async_trait(?Send)]
9297    impl crate::gasp::GASPRemote for BudgetPageRemote {
9298        async fn get_initial_response(
9299            &self,
9300            request: &crate::types::GASPInitialRequest,
9301        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9302            self.requests.borrow_mut().push(request.clone());
9303            let page = self
9304                .outputs
9305                .iter()
9306                .filter(|u| u.score as u64 >= request.since);
9307            let limit = self.clamp.map_or(usize::MAX, |cap| {
9308                cap.min(request.limit.unwrap_or(u64::MAX) as usize)
9309            });
9310            Ok(crate::types::GASPInitialResponse {
9311                utxo_list: page.take(limit).cloned().collect(),
9312                since: request.since,
9313            })
9314        }
9315
9316        async fn get_initial_reply(
9317            &self,
9318            _: &crate::types::GASPInitialResponse,
9319        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9320            unreachable!()
9321        }
9322
9323        async fn request_node(
9324            &self,
9325            graph_id: &str,
9326            txid: &str,
9327            _: u32,
9328            _: bool,
9329        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9330            self.asked.borrow_mut().push(txid.to_string());
9331            self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
9332            let slow = {
9333                let mut slow = self.slow_once.borrow_mut();
9334                match slow.as_ref() {
9335                    Some((t, _)) if t == txid => slow.take().map(|(_, ms)| ms),
9336                    _ => None,
9337                }
9338            };
9339            if let Some(ms) = slow {
9340                self.elapsed.set(self.elapsed.get() + ms);
9341            }
9342            if self.faults_for.borrow().contains(txid) {
9343                return Err(crate::gasp::GASPError::RemoteError("HTTP 503".into()));
9344            }
9345            if self.node_cost_ms > 0 {
9346                let mut yielded = false;
9347                std::future::poll_fn(|cx| {
9348                    if yielded {
9349                        std::task::Poll::Ready(())
9350                    } else {
9351                        yielded = true;
9352                        cx.waker().wake_by_ref();
9353                        std::task::Poll::Pending
9354                    }
9355                })
9356                .await;
9357            }
9358            let mut node = self
9359                .nodes
9360                .get(txid)
9361                .cloned()
9362                .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
9363            node.graph_id = graph_id.to_string();
9364            Ok(node)
9365        }
9366
9367        async fn submit_node(
9368            &self,
9369            _: &crate::types::GASPNode,
9370        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9371            unreachable!()
9372        }
9373    }
9374
9375    impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
9376        fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
9377            Box::new(self.clone())
9378        }
9379    }
9380
9381    fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
9382        let mut engine = make_engine(vec![0]);
9383        let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
9384        let mut outputs = Vec::new();
9385        let mut nodes = HashMap::new();
9386        for n in 1..=count {
9387            let tx = child_of(&format!("{n:064x}"), 0);
9388            let txid = tx.id();
9389            tracker.add_root(n, txid.clone());
9390            outputs.push(crate::types::GASPOutput {
9391                txid: txid.clone(),
9392                output_index: 0,
9393                score: f64::from(n * 10),
9394            });
9395            nodes.insert(
9396                txid.clone(),
9397                crate::types::GASPNode {
9398                    graph_id: String::new(),
9399                    raw_tx: tx.to_hex(),
9400                    output_index: 0,
9401                    proof: Some(single_leaf_bump_hex(&txid, n)),
9402                    tx_metadata: None,
9403                    output_metadata: None,
9404                    inputs: None,
9405                },
9406            );
9407        }
9408        engine.chain_tracker = Some(Box::new(tracker));
9409        engine.config.sync_configuration.insert(
9410            "tm_test".to_string(),
9411            SyncTarget::Peers(vec!["https://budget.example".to_string()]),
9412        );
9413        let remote = BudgetPageRemote {
9414            outputs: std::rc::Rc::new(outputs),
9415            nodes: std::rc::Rc::new(nodes),
9416            elapsed: std::rc::Rc::new(Cell::new(0)),
9417            requests: std::rc::Rc::default(),
9418            clamp: None,
9419            node_cost_ms: 5000,
9420            slow_once: std::rc::Rc::default(),
9421            asked: std::rc::Rc::default(),
9422            faults_for: std::rc::Rc::default(),
9423        };
9424        (engine, remote)
9425    }
9426
9427    fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
9428        let elapsed = remote.elapsed.clone();
9429        engine.set_peer_sync_budget(
9430            std::rc::Rc::new(move |ms| {
9431                assert_eq!(ms, 30_000);
9432                let elapsed = elapsed.clone();
9433                let until = elapsed.get() + ms;
9434                Box::pin(std::future::poll_fn(move |_| {
9435                    if elapsed.get() >= until {
9436                        std::task::Poll::Ready(())
9437                    } else {
9438                        std::task::Poll::Pending
9439                    }
9440                }))
9441            }),
9442            30_000,
9443        );
9444    }
9445
9446    #[tokio::test]
9447    async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
9448        // This responder ignores the limit: the initiator must safely handle
9449        // a 3,000-output page under a virtual 30-second peer budget.
9450        let (mut engine, remote) = budget_page_engine(3000);
9451        virtual_peer_budget(&mut engine, &remote);
9452        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9453        for round in 1..=2 {
9454            let result = engine.start_gasp_sync().await.unwrap();
9455            let topic = &result.topics_synced["tm_test"];
9456            assert_eq!(topic.finalized_graphs, 5);
9457            assert_eq!(topic.deadline_dropped_graphs, 1);
9458            assert_eq!(topic.cursor_moves.len(), 1);
9459            let expected = round * 50;
9460            assert_eq!(topic.cursor_moves[0].to, expected);
9461            assert_eq!(
9462                engine
9463                    .storage
9464                    .get_last_interaction("https://budget.example", "tm_test")
9465                    .await
9466                    .unwrap(),
9467                expected
9468            );
9469            {
9470                let requests = remote.requests.borrow();
9471                assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
9472            }
9473            for output in remote.outputs.iter().take((round * 5) as usize) {
9474                assert!(engine
9475                    .storage
9476                    .find_output(&output.txid, 0, Some("tm_test"), None, true)
9477                    .await
9478                    .unwrap()
9479                    .is_some());
9480            }
9481            let unfinished = &remote.outputs[(round * 5) as usize];
9482            assert!(engine
9483                .storage
9484                .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
9485                .await
9486                .unwrap()
9487                .is_none());
9488        }
9489    }
9490
9491    #[tokio::test]
9492    async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
9493        let (mut engine, mut remote) = budget_page_engine(7);
9494        remote.clamp = Some(3);
9495        remote.node_cost_ms = 0;
9496        virtual_peer_budget(&mut engine, &remote);
9497        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9498        let result = engine.start_gasp_sync().await.unwrap();
9499        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
9500        let requests = remote.requests.borrow();
9501        assert_eq!(requests.len(), 4);
9502        assert!(requests.iter().all(|request| request.limit == Some(100)));
9503        assert_eq!(
9504            requests.iter().map(|r| r.since).collect::<Vec<_>>(),
9505            vec![0, 30, 50, 70]
9506        );
9507    }
9508
9509    #[tokio::test]
9510    async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
9511        let (mut engine, mut remote) = budget_page_engine(3000);
9512        std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
9513        virtual_peer_budget(&mut engine, &remote);
9514        engine.set_gasp_remote_factory(Box::new(remote));
9515        let result = engine.start_gasp_sync().await.unwrap();
9516        let topic = &result.topics_synced["tm_test"];
9517        assert_eq!(topic.finalized_graphs, 5);
9518        // Score 100 landed, but score 10 is still ahead in this unordered
9519        // page. The unfinished lower score must hold the cursor at entry.
9520        assert!(topic.cursor_moves.is_empty());
9521    }
9522
9523    #[tokio::test]
9524    async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
9525        let (mut engine, mut remote) = budget_page_engine(3);
9526        std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
9527        remote.node_cost_ms = 10_000;
9528        virtual_peer_budget(&mut engine, &remote);
9529        engine.set_gasp_remote_factory(Box::new(remote));
9530        let result = engine.start_gasp_sync().await.unwrap();
9531        let topic = &result.topics_synced["tm_test"];
9532        assert_eq!(topic.finalized_graphs, 2);
9533        assert_eq!(topic.cursor_moves[0].to, 10);
9534    }
9535
9536    #[tokio::test]
9537    async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
9538        let (mut engine, mut remote) = budget_page_engine(3000);
9539        let failed = remote.outputs[2].txid.clone();
9540        std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
9541        virtual_peer_budget(&mut engine, &remote);
9542        engine.set_gasp_remote_factory(Box::new(remote));
9543        let result = engine.start_gasp_sync().await.unwrap();
9544        let topic = &result.topics_synced["tm_test"];
9545        assert_eq!(topic.finalized_graphs, 4);
9546        assert_eq!(topic.cursor_moves[0].to, 20);
9547    }
9548
9549    struct ProofOnlyRootFetcher {
9550        proofs: HashMap<String, String>,
9551        requested: std::rc::Rc<Cell<u64>>,
9552    }
9553
9554    #[async_trait(?Send)]
9555    impl AncestorFetcher for ProofOnlyRootFetcher {
9556        async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
9557            assert!(
9558                self.requested.get() > 0,
9559                "root proof request must precede ancestry"
9560            );
9561            Err(GASPError::RemoteError("raw source unavailable".to_string()))
9562        }
9563        async fn verified_proof_for(&self, txid: &str) -> Option<String> {
9564            self.requested.set(self.requested.get() + 1);
9565            self.proofs.get(txid).cloned()
9566        }
9567    }
9568
9569    #[tokio::test]
9570    async fn copy_sync_leaves_completion_courier_untouched() {
9571        let (mut engine, mut remote) = budget_page_engine(1);
9572        remote.node_cost_ms = 0;
9573        let txid = remote.outputs[0].txid.clone();
9574        let proof = std::rc::Rc::make_mut(&mut remote.nodes)
9575            .get_mut(&txid)
9576            .unwrap()
9577            .proof
9578            .take()
9579            .unwrap();
9580        let completion_asks = std::rc::Rc::new(Cell::new(0));
9581        let sync_asks = std::rc::Rc::new(Cell::new(0));
9582        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9583            proofs: HashMap::new(),
9584            requested: completion_asks.clone(),
9585        }));
9586        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9587            proofs: HashMap::from([(txid.clone(), proof.clone())]),
9588            requested: sync_asks.clone(),
9589        }));
9590        engine.set_gasp_remote_factory(Box::new(remote));
9591        let result = engine.start_gasp_sync().await.unwrap();
9592        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
9593        assert_eq!(result.topics_synced["tm_test"].roots_proven, 1);
9594        assert_eq!(result.topics_synced["tm_test"].roots_unproven, 0);
9595        let stored = engine
9596            .storage()
9597            .find_output(&txid, 0, Some("tm_test"), None, true)
9598            .await
9599            .unwrap()
9600            .unwrap();
9601        assert!(
9602            crate::beef_limits::own_proof(stored.beef.as_ref().unwrap(), &txid)
9603                .unwrap()
9604                .is_some()
9605        );
9606        assert_eq!(completion_asks.get(), 0);
9607        assert_eq!(sync_asks.get(), 1);
9608        let served = engine
9609            .provide_foreign_gasp_node(&format!("{txid}.0"), &txid, 0)
9610            .await
9611            .unwrap();
9612        assert_eq!(served.proof, Some(proof));
9613    }
9614
9615    #[tokio::test]
9616    async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
9617        for proven in [true, false] {
9618            let (mut engine, mut remote) = budget_page_engine(1);
9619            let txid = remote.outputs[0].txid.clone();
9620            let proof = remote.nodes[&txid].proof.clone().unwrap();
9621            std::rc::Rc::make_mut(&mut remote.nodes)
9622                .get_mut(&txid)
9623                .unwrap()
9624                .proof = None;
9625            let requested = std::rc::Rc::new(Cell::new(0));
9626            engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9627                proofs: if proven {
9628                    HashMap::from([(txid.clone(), proof)])
9629                } else {
9630                    HashMap::new()
9631                },
9632                requested: requested.clone(),
9633            }));
9634            virtual_peer_budget(&mut engine, &remote);
9635            engine.set_gasp_remote_factory(Box::new(remote));
9636            let result = engine.start_gasp_sync().await.unwrap();
9637            assert!(requested.get() > 0);
9638            assert_eq!(
9639                result.topics_synced["tm_test"].finalized_graphs,
9640                u64::from(proven)
9641            );
9642            assert_eq!(
9643                engine
9644                    .storage
9645                    .find_output(&txid, 0, Some("tm_test"), None, true)
9646                    .await
9647                    .unwrap()
9648                    .is_some(),
9649                proven
9650            );
9651            if !proven {
9652                assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
9653            }
9654        }
9655    }
9656
9657    #[tokio::test]
9658    async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
9659        let (mut engine, mut remote) = budget_page_engine(1);
9660        let txid = remote.outputs[0].txid.clone();
9661        // Engine 0.1.15: the peer is asked for the parent the fetcher cannot
9662        // serve. This witness's subject is a paid walk kept as a deferral,
9663        // so the peer answers that parent with a transport fault (#56).
9664        let parent = bsv_rs::transaction::Transaction::from_hex(&remote.nodes[&txid].raw_tx)
9665            .unwrap()
9666            .inputs[0]
9667            .source_txid
9668            .clone()
9669            .unwrap();
9670        remote.faults_for.borrow_mut().insert(parent);
9671        let proof = remote.nodes[&txid].proof.clone().unwrap();
9672        std::rc::Rc::make_mut(&mut remote.nodes)
9673            .get_mut(&txid)
9674            .unwrap()
9675            .proof = None;
9676        let requested = std::rc::Rc::new(Cell::new(0));
9677        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9678            proofs: HashMap::new(),
9679            requested: requested.clone(),
9680        }));
9681        virtual_peer_budget(&mut engine, &remote);
9682        engine.set_graph_budget(
9683            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9684            5,
9685            5000,
9686        );
9687        engine.set_gasp_remote_factory(Box::new(remote));
9688        let held = engine.start_gasp_sync().await.unwrap();
9689        assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
9690        assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
9691        assert_eq!(held.topics_synced["tm_test"].cursor_moves[0].to, 10);
9692        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9693            proofs: HashMap::from([(txid.clone(), proof)]),
9694            requested,
9695        }));
9696        engine
9697            .storage()
9698            .update_last_interaction("https://budget.example", "tm_test", 20)
9699            .await
9700            .unwrap();
9701        let landed = engine.start_gasp_sync().await.unwrap();
9702        assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
9703        assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
9704        assert!(landed.topics_synced["tm_test"].cursor_moves.is_empty());
9705        assert!(engine
9706            .storage
9707            .find_output(&txid, 0, Some("tm_test"), None, true)
9708            .await
9709            .unwrap()
9710            .is_some());
9711    }
9712
9713    #[tokio::test]
9714    async fn proofs_deadline_stalls_become_durable_after_three_runs() {
9715        let (mut engine, remote) = budget_page_engine(1);
9716        let elapsed = remote.elapsed.clone();
9717        let root = format!("{}.0", remote.outputs[0].txid);
9718        engine.set_graph_budget(
9719            std::rc::Rc::new(move |ms| {
9720                let until = elapsed.get() + ms;
9721                let elapsed = elapsed.clone();
9722                Box::pin(std::future::poll_fn(move |_| {
9723                    if elapsed.get() >= until {
9724                        std::task::Poll::Ready(())
9725                    } else {
9726                        std::task::Poll::Pending
9727                    }
9728                }))
9729            }),
9730            10,
9731            1_000,
9732        );
9733        engine.set_gasp_remote_factory(Box::new(remote));
9734        for round in 1..=crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9735            let result = engine.start_gasp_sync().await.unwrap();
9736            let topic = &result.topics_synced["tm_test"];
9737            if round < crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9738                assert!(topic.cursor_moves.is_empty());
9739                assert_eq!(topic.cursor_held_by, vec![root.clone()]);
9740            } else {
9741                assert_eq!(topic.cursor_moves[0].to, 10);
9742                assert_eq!(topic.deferred_graphs, 1);
9743            }
9744        }
9745        let held = engine
9746            .storage()
9747            .get_deferred_graph("https://budget.example", "tm_test", &root)
9748            .await
9749            .unwrap()
9750            .unwrap();
9751        assert!(held.cursor_passable);
9752        assert!(held.nodes.is_empty());
9753    }
9754
9755    /// 0.1.8 (the cursor): a graph the per-PEER deadline cuts before its
9756    /// root comes back is held at once (`peer_deadline`), the cursor passes
9757    /// it to the page's last finished score, and the next run resumes it
9758    /// ahead of the pages until it converges. On 0.1.7 the cut was a retry
9759    /// for three runs, the cursor stayed at 20 and `cursor_held_by` named it.
9760    #[tokio::test]
9761    async fn cursor_018_the_graph_in_flight_at_the_peer_deadline_is_held_and_passed() {
9762        let (mut engine, remote) = budget_page_engine(6);
9763        let slow = remote.outputs[2].txid.clone();
9764        let root = format!("{slow}.0");
9765        *remote.slow_once.borrow_mut() = Some((slow.clone(), 60_000));
9766        virtual_peer_budget(&mut engine, &remote);
9767        engine.set_graph_budget(
9768            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9769            100,
9770            60_000,
9771        );
9772        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9773        let result = engine.start_gasp_sync().await.unwrap();
9774        let topic = &result.topics_synced["tm_test"];
9775        assert_eq!(topic.finalized_graphs, 2);
9776        assert_eq!(topic.deadline_dropped_graphs, 1);
9777        assert_eq!(topic.deferred_graphs, 1);
9778        assert_eq!(topic.cursor_moves.len(), 1);
9779        assert_eq!(topic.cursor_moves[0].to, 30, "past the held graph");
9780        assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
9781        let held = engine
9782            .storage()
9783            .get_deferred_graph("https://budget.example", "tm_test", &root)
9784            .await
9785            .unwrap()
9786            .unwrap();
9787        assert!(held.cursor_passable);
9788        assert_eq!(held.reason, "peer_deadline");
9789        assert!(held.nodes.is_empty());
9790        assert_eq!(held.score, 30);
9791
9792        let result = engine.start_gasp_sync().await.unwrap();
9793        let topic = &result.topics_synced["tm_test"];
9794        assert_eq!(topic.resumed_graphs, 1);
9795        assert_eq!(topic.converged_graphs, 1);
9796        assert_eq!(topic.finalized_graphs, 4, "the held graph and 40, 50, 60");
9797        assert_eq!(topic.cursor_moves[0].to, 60);
9798        assert!(engine
9799            .storage()
9800            .get_deferred_graph("https://budget.example", "tm_test", &root)
9801            .await
9802            .unwrap()
9803            .is_none());
9804        for output in remote.outputs.iter() {
9805            assert!(engine
9806                .storage
9807                .find_output(&output.txid, 0, Some("tm_test"), None, true)
9808                .await
9809                .unwrap()
9810                .is_some());
9811        }
9812    }
9813
9814    /// Every root's advert expired (the walk refuses each after its node).
9815    struct ExpiredRoots(MockTopicManager);
9816
9817    #[async_trait(?Send)]
9818    impl TopicManagerTrait for ExpiredRoots {
9819        async fn identify_admissible_outputs(
9820            &self,
9821            tx: &Transaction,
9822            previous_coins: &[u8],
9823            off_chain_values: Option<&[u8]>,
9824            mode: SubmitMode,
9825            context: &TopicAdmittanceContext,
9826        ) -> Result<AdmittanceInstructions, TopicManagerError> {
9827            self.0
9828                .identify_admissible_outputs(tx, previous_coins, off_chain_values, mode, context)
9829                .await
9830        }
9831        async fn get_documentation(&self) -> String {
9832            self.0.get_documentation().await
9833        }
9834        async fn get_metadata(&self) -> ServiceMetadata {
9835            self.0.get_metadata().await
9836        }
9837        fn sync_expiry(&self, _: &bsv_rs::script::LockingScript) -> Option<u64> {
9838            Some(1)
9839        }
9840    }
9841
9842    /// A page of dead adverts above one root the peer cannot serve (a gap
9843    /// the cursor holds below), on a shared store.
9844    fn dead_page_engine(count: u32) -> (Engine, BudgetPageRemote, std::rc::Rc<MemoryStorage>) {
9845        let (mut engine, mut remote) = budget_page_engine(count);
9846        let gap = remote.outputs[0].txid.clone();
9847        std::rc::Rc::make_mut(&mut remote.nodes).remove(&gap);
9848        engine.managers.insert(
9849            "tm_test".to_string(),
9850            Box::new(ExpiredRoots(MockTopicManager::admitting(vec![0]))),
9851        );
9852        let store = std::rc::Rc::new(MemoryStorage::new());
9853        engine.storage = Box::new(store.clone());
9854        engine.set_graph_budget(
9855            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9856            100,
9857            60_000,
9858        );
9859        (engine, remote, store)
9860    }
9861
9862    /// 0.1.8: a sync that only refused expired adverts (the cursor held by
9863    /// a gap below them) did work: a yield, never a step of the yieldless
9864    /// streak. On 0.1.7 it was yieldless.
9865    #[tokio::test]
9866    async fn cursor_018_a_sync_that_only_refuses_the_dead_is_a_yield() {
9867        let (mut engine, mut remote, store) = dead_page_engine(3);
9868        remote.node_cost_ms = 0;
9869        virtual_peer_budget(&mut engine, &remote);
9870        engine.set_gasp_remote_factory(Box::new(remote));
9871        let result = engine.start_gasp_sync().await.unwrap();
9872        let topic = &result.topics_synced["tm_test"];
9873        assert_eq!(topic.expired_refused, 2);
9874        assert_eq!(topic.finalized_graphs, 0);
9875        assert!(topic.cursor_moves.is_empty());
9876        let host = crate::gasp::peer_origin("https://budget.example");
9877        assert_eq!(store.peer_yieldless_syncs(&host, "tm_test"), 0);
9878        let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9879        assert_eq!(health.consecutive_failures, 0);
9880    }
9881
9882    /// 0.1.8: a peer that served every page and every node, whose slice
9883    /// the per-peer deadline ended in each of nine runs in a row while it
9884    /// refused dead adverts (the cursor held by a gap below them), is never
9885    /// quarantined: each run did work. On 0.1.7 each was a failed attempt
9886    /// and the ninth was skipped.
9887    #[tokio::test]
9888    async fn cursor_018_deadline_drops_in_a_row_do_not_quarantine_a_serving_peer() {
9889        let (mut engine, remote, store) = dead_page_engine(200);
9890        virtual_peer_budget(&mut engine, &remote);
9891        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9892        let host = crate::gasp::peer_origin("https://budget.example");
9893        let runs = crate::gasp::PEER_QUARANTINE_THRESHOLD + 1;
9894        let gap = format!("{}.0", remote.outputs[0].txid);
9895        for run in 1..=runs {
9896            // Engine 0.1.15: the gap root is absence (`not_found`), counted
9897            // on runs at least ABSENCE_GAP_SECS apart; these runs follow at
9898            // once, so the gap is passed by hand (#56).
9899            if let Some(mut record) = store
9900                .get_deferred_graph("https://budget.example", "tm_test", &gap)
9901                .await
9902                .unwrap()
9903                .filter(|r| r.reason.starts_with("retry:fault:"))
9904            {
9905                record.retry_at = 0;
9906                store.put_deferred_graph(&record).await.unwrap();
9907            }
9908            let asked = remote.asked.borrow().len();
9909            let result = engine.start_gasp_sync().await.unwrap();
9910            let topic = &result.topics_synced["tm_test"];
9911            assert!(remote.asked.borrow().len() > asked, "run {run}: attempted");
9912            assert_eq!(topic.deadline_dropped_graphs, 1, "run {run}");
9913            assert!(topic.expired_refused > 0, "run {run}: {topic:?}");
9914            // 0.1.9: the gap is a root the peer lists and does not serve
9915            // (`not_found` since 0.1.15, `not_served` before); its third
9916            // faulted run records it unanchorable and the cursor passes it.
9917            if run < u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9918                assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
9919            } else if run == u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9920                assert_eq!(topic.unanchorable_graphs, 1, "run {run}: {topic:?}");
9921                assert!(!topic.cursor_moves.is_empty(), "run {run}: passed");
9922            }
9923            let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9924            assert_eq!(health.consecutive_failures, 0, "run {run}");
9925        }
9926        // A refused root is never fetched again. The one root fetched twice
9927        // per cut is the graph in flight: its answer was lost with the slice
9928        // and its hold is resumed (and refused) by the next run.
9929        let asked = remote.asked.borrow();
9930        let mut dead: Vec<&String> = asked
9931            .iter()
9932            .filter(|t| **t != remote.outputs[0].txid)
9933            .collect();
9934        let fetched = dead.len();
9935        dead.sort();
9936        dead.dedup();
9937        assert_eq!(
9938            fetched - dead.len(),
9939            (runs - 1) as usize,
9940            "one per resumed cut"
9941        );
9942        assert_eq!(store.expired_outpoints("tm_test").len(), dead.len() - 1);
9943    }
9944
9945    /// A courier that serves ONE ancestor, and only once told to (engine
9946    /// 0.1.9): before, every rung "could not serve" it.
9947    struct GatedCourier {
9948        txid: String,
9949        raw_tx: String,
9950        proof: String,
9951        serve: std::rc::Rc<Cell<bool>>,
9952        asked: std::rc::Rc<Cell<u64>>,
9953    }
9954
9955    #[async_trait(?Send)]
9956    impl AncestorFetcher for GatedCourier {
9957        async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
9958            self.asked.set(self.asked.get() + 1);
9959            if self.serve.get() && txid == self.txid {
9960                Ok(FetchedAncestor {
9961                    raw_tx: self.raw_tx.clone(),
9962                    proof: Some(self.proof.clone()),
9963                })
9964            } else {
9965                Err(GASPError::RemoteError(format!(
9966                    "no rung served the raw of {txid}"
9967                )))
9968            }
9969        }
9970        async fn verified_proof_for(&self, _: &str) -> Option<String> {
9971            None
9972        }
9973    }
9974
9975    /// The fixture of 0.1.9: a page of three (scores 10, 20, 30) whose
9976    /// lowest root is served UNPROVEN, so its walk needs its parent, which
9977    /// only the courier can serve (a mined parent, proven at height 900).
9978    #[tokio::test]
9979    async fn load_0112_definitive_absence_strikes_a_resumed_obligation() {
9980        let mut fixture = obligation_fixture(1, 1, 0).await;
9981        fixture
9982            .engine
9983            .set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
9984                calls: std::rc::Rc::new(Cell::new(0)),
9985                bounded: false,
9986            }));
9987        // Engine 0.1.15 (#56): the rungs' absence counts one run, never a
9988        // strike by itself (it struck at once on 0.1.12 to 0.1.14); three
9989        // runs with a gap strike it. The obligation stays passable.
9990        let host = "https://budget.example";
9991        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
9992        for run in 1..=runs {
9993            let result = fixture.engine.start_gasp_sync().await.unwrap();
9994            let topic = &result.topics_synced["tm_test"];
9995            let record = fixture
9996                .store
9997                .get_deferred_graph(host, "tm_test", &fixture.obligations[0])
9998                .await
9999                .unwrap()
10000                .unwrap();
10001            assert_eq!(record.faults, run);
10002            assert!(record.cursor_passable);
10003            if run == 1 {
10004                assert_eq!(topic.finalized_graphs, 1);
10005            }
10006            if run < runs {
10007                assert_eq!(topic.unanchorable_graphs, 0);
10008                assert_eq!(record.reason, "retry:fault:not_found");
10009                let mut record = record;
10010                record.retry_at = 0;
10011                fixture.store.put_deferred_graph(&record).await.unwrap();
10012            } else {
10013                assert_eq!(topic.unanchorable_graphs, 1);
10014                assert_eq!(record.reason, "unanchorable:not_found");
10015            }
10016        }
10017    }
10018
10019    #[tokio::test]
10020    async fn load_0112_root_time_is_clamped_and_named() {
10021        let (mut engine, mut remote) = budget_page_engine(1);
10022        remote.node_cost_ms = 16_000;
10023        let elapsed = remote.elapsed.clone();
10024        let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10025            let clock = elapsed.clone();
10026            let due = clock.get() + ms;
10027            Box::pin(std::future::poll_fn(move |_| {
10028                if clock.get() >= due {
10029                    std::task::Poll::Ready(())
10030                } else {
10031                    std::task::Poll::Pending
10032                }
10033            }))
10034        });
10035        engine.set_peer_sync_budget(sleep.clone(), 30_000);
10036        engine.set_graph_budget(sleep, 100, 60_000);
10037        engine.set_gasp_remote_factory(Box::new(remote));
10038        let result = engine.start_gasp_sync().await.unwrap();
10039        let topic = &result.topics_synced["tm_test"];
10040        assert_eq!(topic.finalized_graphs, 0);
10041        assert_eq!(topic.root_bounds[0].reason, "time");
10042        assert!(!topic.cursor_held_by.is_empty() || topic.deferred_graphs > 0);
10043    }
10044
10045    #[tokio::test]
10046    async fn load_0112_storage_counts_are_attempts_at_the_boundary() {
10047        let engine = make_engine(vec![0]);
10048        let meter = crate::meter::LoadMeter::default();
10049        meter
10050            .scope(async {
10051                assert_eq!(
10052                    engine
10053                        .storage
10054                        .get_last_interaction("peer", "topic")
10055                        .await
10056                        .unwrap(),
10057                    0
10058                );
10059                engine
10060                    .storage
10061                    .update_last_interaction("peer", "topic", 12)
10062                    .await
10063                    .unwrap();
10064                assert_eq!(
10065                    engine
10066                        .storage
10067                        .get_last_interaction("peer", "topic")
10068                        .await
10069                        .unwrap(),
10070                    12
10071                );
10072            })
10073            .await;
10074        assert_eq!(
10075            (
10076                meter.snapshot().storage_reads,
10077                meter.snapshot().storage_writes
10078            ),
10079            (2, 1)
10080        );
10081        engine
10082            .storage
10083            .get_last_interaction("peer", "topic")
10084            .await
10085            .unwrap();
10086        assert_eq!(meter.snapshot().storage_reads, 2);
10087    }
10088
10089    struct LoadCourier {
10090        calls: std::rc::Rc<Cell<u64>>,
10091        bounded: bool,
10092    }
10093    #[async_trait(?Send)]
10094    impl crate::gasp::AncestorFetcher for LoadCourier {
10095        async fn fetch_ancestor(
10096            &self,
10097            txid: &str,
10098        ) -> Result<crate::gasp::FetchedAncestor, crate::gasp::GASPError> {
10099            self.calls.set(self.calls.get() + 1);
10100            if self.bounded {
10101                for _ in 0..13 {
10102                    crate::meter::courier_request()
10103                        .map_err(|e| crate::gasp::GASPError::Other(e.into()))?;
10104                }
10105                Err(crate::gasp::GASPError::RemoteError(
10106                    "still missing a proof".into(),
10107                ))
10108            } else {
10109                for _ in 0..3 {
10110                    crate::meter::courier_request().unwrap();
10111                }
10112                Err(crate::gasp::GASPError::DefinitiveNotFound(txid.into()))
10113            }
10114        }
10115        async fn verified_proof_for(&self, _: &str) -> Option<String> {
10116            None
10117        }
10118    }
10119
10120    #[tokio::test]
10121    async fn load_0112_definitive_absence_is_shared_across_peers_and_topics() {
10122        let UnanchorableFixture {
10123            mut engine,
10124            remote,
10125            root,
10126            ..
10127        } = unanchorable_fixture();
10128        let calls = std::rc::Rc::new(Cell::new(0));
10129        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10130            calls: calls.clone(),
10131            bounded: false,
10132        }));
10133        engine.managers.insert(
10134            "tm_other".into(),
10135            Box::new(MockTopicManager::admitting(vec![0])),
10136        );
10137        for topic in ["tm_test", "tm_other"] {
10138            engine.config.sync_configuration.insert(
10139                topic.into(),
10140                SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
10141            );
10142        }
10143        let result = engine.start_gasp_sync().await.unwrap();
10144        assert_eq!(calls.get(), 1, "one ladder per missing ancestor per run");
10145        assert_eq!(result.cost.courier_not_found_memo_hits, 3);
10146        assert_eq!(result.cost.courier_rungs, 3);
10147        for (topic, res) in &result.topics_synced {
10148            // Engine 0.1.15 (#56): the rungs' absence counts one run on each
10149            // (peer, topic), not a strike (2 unanchorable on 0.1.12 to 0.1.14).
10150            assert_eq!(res.unanchorable_graphs, 0, "{res:?}");
10151            assert_eq!(
10152                res.cost.courier_rungs + res.cost.peer_requests,
10153                res.cost.calls_out
10154            );
10155            for host in ["https://a.example", "https://b.example"] {
10156                let mut record = engine
10157                    .storage
10158                    .get_deferred_graph(host, topic, &root)
10159                    .await
10160                    .unwrap()
10161                    .unwrap();
10162                assert_eq!(record.reason, "retry:fault:not_found");
10163                assert_eq!(record.faults, 1);
10164                assert!(!record.cursor_passable);
10165                record.retry_at = 0;
10166                engine.storage.put_deferred_graph(&record).await.unwrap();
10167            }
10168        }
10169        assert!(!remote.asked.borrow().is_empty());
10170        engine.start_gasp_sync().await.unwrap();
10171        assert_eq!(calls.get(), 2, "another run can retry absence");
10172    }
10173
10174    #[tokio::test]
10175    async fn load_0112_courier_bound_defers_the_root_and_names_it() {
10176        let UnanchorableFixture {
10177            mut engine, root, ..
10178        } = unanchorable_fixture();
10179        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10180            calls: std::rc::Rc::new(Cell::new(0)),
10181            bounded: true,
10182        }));
10183        let result = engine.start_gasp_sync().await.unwrap();
10184        let record = engine
10185            .storage
10186            .get_deferred_graph("https://budget.example", "tm_test", &root)
10187            .await
10188            .unwrap()
10189            .expect("bounded root must be retained");
10190        assert_eq!(record.reason, "courier_rungs");
10191        assert!(!record.nodes.is_empty());
10192        assert_eq!(result.cost.courier_rungs, 12);
10193        assert_eq!(
10194            result.topics_synced["tm_test"].root_bounds[0].reason,
10195            "courier_rungs"
10196        );
10197        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 2);
10198    }
10199
10200    #[tokio::test]
10201    async fn load_0112_refused_save_does_not_restart_a_bounded_root() {
10202        let UnanchorableFixture {
10203            mut engine,
10204            store,
10205            root,
10206            ..
10207        } = unanchorable_fixture();
10208        store.set_deferred_graph_ceiling(Some(0));
10209        let calls = std::rc::Rc::new(Cell::new(0));
10210        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10211            calls: calls.clone(),
10212            bounded: true,
10213        }));
10214        let result = engine.start_gasp_sync().await.unwrap();
10215        let topic = &result.topics_synced["tm_test"];
10216        assert_eq!(calls.get(), 1, "no retry after a hard root cut");
10217        assert_eq!(topic.root_bounds.len(), 1);
10218        assert_eq!(result.cost.courier_rungs, 12);
10219        assert!(topic.cursor_held_by.contains(&root));
10220        assert_eq!(
10221            topic.strikes_refused, 0,
10222            "a budget cut is not a fault strike"
10223        );
10224        assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10225        assert_eq!(topic.finalized_graphs, 2);
10226    }
10227
10228    #[tokio::test]
10229    async fn load_0112_refused_save_does_not_extend_root_time() {
10230        let (mut engine, mut remote) = budget_page_engine(1);
10231        remote.node_cost_ms = 16_000;
10232        let elapsed = remote.elapsed.clone();
10233        let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10234            let clock = elapsed.clone();
10235            let due = clock.get() + ms;
10236            Box::pin(std::future::poll_fn(move |_| {
10237                if clock.get() >= due {
10238                    std::task::Poll::Ready(())
10239                } else {
10240                    std::task::Poll::Pending
10241                }
10242            }))
10243        });
10244        let store = std::rc::Rc::new(MemoryStorage::new());
10245        store.set_deferred_graph_ceiling(Some(0));
10246        engine.storage = Box::new(store);
10247        engine.set_peer_sync_budget(sleep.clone(), 30_000);
10248        engine.set_graph_budget(sleep, 100, 60_000);
10249        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10250        let result = engine.start_gasp_sync().await.unwrap();
10251        let topic = &result.topics_synced["tm_test"];
10252        assert_eq!(
10253            remote.asked.borrow().len(),
10254            1,
10255            "no request after the time cut"
10256        );
10257        assert_eq!(topic.root_bounds.len(), 1);
10258        assert_eq!(topic.root_bounds[0].reason, "time");
10259        assert_eq!(topic.finalized_graphs, 0);
10260        assert!(!topic.cursor_held_by.is_empty());
10261    }
10262
10263    #[tokio::test]
10264    async fn load_0112_call_limit_fallback_keeps_the_root_deadline() {
10265        let UnanchorableFixture {
10266            mut engine,
10267            mut remote,
10268            store,
10269            root,
10270            serve,
10271            ..
10272        } = unanchorable_fixture();
10273        store.set_deferred_graph_ceiling(Some(0));
10274        serve.set(true);
10275        remote.node_cost_ms = 8_000;
10276        struct DelayedCourier {
10277            inner: std::rc::Rc<dyn AncestorFetcher>,
10278            clock: std::rc::Rc<Cell<u64>>,
10279        }
10280        #[async_trait(?Send)]
10281        impl AncestorFetcher for DelayedCourier {
10282            async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10283                self.clock.set(self.clock.get() + 8_000);
10284                tokio::task::yield_now().await;
10285                self.inner.fetch_ancestor(txid).await
10286            }
10287            async fn verified_proof_for(&self, txid: &str) -> Option<String> {
10288                self.inner.verified_proof_for(txid).await
10289            }
10290        }
10291        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(DelayedCourier {
10292            inner: engine.gasp_ancestor_fetcher.as_ref().unwrap().clone(),
10293            clock: remote.elapsed.clone(),
10294        }));
10295        let elapsed = remote.elapsed.clone();
10296        let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10297            let clock = elapsed.clone();
10298            let due = clock.get() + ms;
10299            Box::pin(std::future::poll_fn(move |_| {
10300                if clock.get() >= due {
10301                    std::task::Poll::Ready(())
10302                } else {
10303                    std::task::Poll::Pending
10304                }
10305            }))
10306        });
10307        engine.set_peer_sync_budget(sleep.clone(), 30_000);
10308        engine.set_graph_budget(sleep, 1, 60_000);
10309        engine.set_gasp_remote_factory(Box::new(remote));
10310        let result = engine.start_gasp_sync().await.unwrap();
10311        let topic = &result.topics_synced["tm_test"];
10312        assert!(
10313            topic
10314                .root_bounds
10315                .iter()
10316                .any(|b| b.outpoint == root && b.reason == "time"),
10317            "{topic:?}"
10318        );
10319        assert!(topic.cursor_held_by.contains(&root));
10320        assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10321    }
10322
10323    #[tokio::test]
10324    async fn load_0112_cost_matches_the_remote_call_log() {
10325        let (mut engine, remote) = budget_page_engine(2);
10326        remote.elapsed.set(0);
10327        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10328        let result = engine.start_gasp_sync().await.unwrap();
10329        let actual_calls = (remote.requests.borrow().len() + remote.asked.borrow().len()) as u64;
10330        assert_eq!(result.cost.peer_requests, actual_calls);
10331        assert_eq!(result.cost.calls_out, actual_calls);
10332        assert!(result.cost.storage_reads > 0);
10333        assert!(result.cost.storage_writes > 0);
10334        assert_eq!(
10335            result.cost.storage_reads,
10336            result.topics_synced["tm_test"].cost.storage_reads
10337        );
10338        assert_eq!(
10339            result.cost.storage_writes,
10340            result.topics_synced["tm_test"].cost.storage_writes
10341        );
10342    }
10343
10344    struct UnanchorableFixture {
10345        engine: Engine,
10346        remote: BudgetPageRemote,
10347        store: std::rc::Rc<MemoryStorage>,
10348        root: String,
10349        serve: std::rc::Rc<Cell<bool>>,
10350        asked: std::rc::Rc<Cell<u64>>,
10351    }
10352
10353    fn unanchorable_fixture() -> UnanchorableFixture {
10354        let (mut engine, mut remote) = budget_page_engine(3);
10355        remote.node_cost_ms = 0;
10356        let parent = child_of(&format!("{:064x}", 0xab), 0);
10357        let child = child_of(&parent.id(), 0);
10358        let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
10359        for n in 1..=3u32 {
10360            tracker.add_root(n, child_of(&format!("{n:064x}"), 0).id());
10361        }
10362        tracker.add_root(900, parent.id());
10363        engine.chain_tracker = Some(Box::new(tracker));
10364        let replaced = std::mem::replace(
10365            &mut std::rc::Rc::make_mut(&mut remote.outputs)[0].txid,
10366            child.id(),
10367        );
10368        let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10369        nodes.remove(&replaced);
10370        nodes.insert(
10371            child.id(),
10372            crate::types::GASPNode {
10373                graph_id: String::new(),
10374                raw_tx: child.to_hex(),
10375                output_index: 0,
10376                proof: None,
10377                tx_metadata: None,
10378                output_metadata: None,
10379                inputs: None,
10380            },
10381        );
10382        // Engine 0.1.15: the peer is asked for an input the courier cannot
10383        // serve. This fixture's subject is the courier's fault, so its peer
10384        // answers that input with a transport fault, which counts nothing
10385        // (#56); the absence witnesses clear it to hear the peer's word.
10386        remote.faults_for.borrow_mut().insert(parent.id());
10387        let serve = std::rc::Rc::new(Cell::new(false));
10388        let asked = std::rc::Rc::new(Cell::new(0));
10389        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(GatedCourier {
10390            txid: parent.id(),
10391            raw_tx: parent.to_hex(),
10392            proof: single_leaf_bump_hex(&parent.id(), 900),
10393            serve: serve.clone(),
10394            asked: asked.clone(),
10395        }));
10396        let store = std::rc::Rc::new(MemoryStorage::new());
10397        engine.storage = Box::new(store.clone());
10398        virtual_peer_budget(&mut engine, &remote);
10399        engine.set_graph_budget(
10400            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10401            100,
10402            60_000,
10403        );
10404        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10405        UnanchorableFixture {
10406            engine,
10407            remote,
10408            store,
10409            root: format!("{}.0", child.id()),
10410            serve,
10411            asked,
10412        }
10413    }
10414
10415    /// 0.1.9 (unanchorable): a root whose ancestor the courier cannot serve
10416    /// faults three runs in a row; each run names it with its class
10417    /// (`courier`) and holds the cursor below it until the third, which
10418    /// records it `unanchorable:courier` and passes the cursor to the page's
10419    /// last finished score. Inside its backoff it costs no request. A later
10420    /// run in which the courier serves the ancestor admits it and clears the
10421    /// record. On 0.1.8 the cursor stayed at 0 on every run (fact 2: the
10422    /// keeper's hand).
10423    #[tokio::test]
10424    async fn unanchorable_019_a_root_faulted_three_runs_is_recorded_passed_and_admitted_later() {
10425        let UnanchorableFixture {
10426            engine,
10427            remote,
10428            store,
10429            root,
10430            serve,
10431            asked,
10432        } = unanchorable_fixture();
10433        let host = "https://budget.example";
10434        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10435        for run in 1..=runs {
10436            let result = engine.start_gasp_sync().await.unwrap();
10437            let topic = &result.topics_synced["tm_test"];
10438            assert_eq!(topic.faulted_roots.len(), 1, "run {run}: {topic:?}");
10439            let named = &topic.faulted_roots[0];
10440            assert_eq!(
10441                (named.peer.as_str(), named.outpoint.as_str()),
10442                (host, root.as_str())
10443            );
10444            assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10445            assert_eq!(named.faults, run, "run {run}");
10446            assert!(named.error.contains("no rung served"), "{named:?}");
10447            let record = engine
10448                .storage()
10449                .get_deferred_graph(host, "tm_test", &root)
10450                .await
10451                .unwrap()
10452                .unwrap();
10453            assert_eq!(record.faults, run);
10454            assert!(record.nodes.is_empty(), "the root alone, asked again");
10455            if run < runs {
10456                assert!(!named.unanchorable);
10457                assert_eq!(topic.unanchorable_graphs, 0);
10458                assert_eq!(record.reason, "retry:fault:courier");
10459                assert!(!record.cursor_passable);
10460                assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10461                assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10462            } else {
10463                assert!(named.unanchorable);
10464                assert_eq!(topic.unanchorable_graphs, 1);
10465                assert_eq!(record.reason, "unanchorable:courier");
10466                assert!(record.cursor_passable);
10467                assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
10468                assert_eq!(topic.cursor_moves.len(), 1);
10469                assert_eq!(
10470                    topic.cursor_moves[0].to, 30,
10471                    "the page's last finished score"
10472                );
10473                assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10474            }
10475            if run == 1 {
10476                assert_eq!(topic.finalized_graphs, 2, "20 and 30");
10477            }
10478        }
10479
10480        // Inside the backoff: the root is not asked of the peer or the
10481        // courier, nothing is named, and the quiet run is no attempt.
10482        let (courier_asks, node_asks) = (asked.get(), remote.asked.borrow().len());
10483        let streak = store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test");
10484        let result = engine.start_gasp_sync().await.unwrap();
10485        let topic = &result.topics_synced["tm_test"];
10486        assert!(topic.faulted_roots.is_empty(), "{topic:?}");
10487        assert!(topic.cursor_held_by.is_empty());
10488        assert_eq!(topic.unanchorable_graphs, 0);
10489        assert_eq!(asked.get(), courier_asks);
10490        assert_eq!(remote.asked.borrow().len(), node_asks);
10491        assert_eq!(
10492            store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test"),
10493            streak
10494        );
10495
10496        // The backoff runs out and the courier serves the ancestor: the
10497        // root is admitted as any root and its record is cleared.
10498        let mut record = engine
10499            .storage()
10500            .get_deferred_graph(host, "tm_test", &root)
10501            .await
10502            .unwrap()
10503            .unwrap();
10504        record.retry_at = 1;
10505        engine.storage().put_deferred_graph(&record).await.unwrap();
10506        serve.set(true);
10507        let result = engine.start_gasp_sync().await.unwrap();
10508        let topic = &result.topics_synced["tm_test"];
10509        assert_eq!(topic.finalized_graphs, 1, "{topic:?}");
10510        assert_eq!(topic.unanchorable_cleared, 1);
10511        assert!(topic.faulted_roots.is_empty());
10512        assert!(engine
10513            .storage()
10514            .get_deferred_graph(host, "tm_test", &root)
10515            .await
10516            .unwrap()
10517            .is_none());
10518        let (txid, _) = crate::gasp::parse_outpoint(&root).unwrap();
10519        assert!(engine
10520            .storage
10521            .find_output(&txid, 0, Some("tm_test"), None, true)
10522            .await
10523            .unwrap()
10524            .is_some());
10525    }
10526
10527    /// 0.1.9: only the run's LOWEST faulted root starts a fault record (the
10528    /// delta fold's D-M1: a peer that faults every node earns one record a
10529    /// run, not one per root); the others are named, class and all, and
10530    /// stay gaps above it.
10531    #[tokio::test]
10532    async fn unanchorable_019_only_the_lowest_faulted_root_is_recorded_and_all_are_named() {
10533        let (mut engine, mut remote) = budget_page_engine(4);
10534        remote.node_cost_ms = 0;
10535        // Engine 0.1.15 (#56): a root the peer does not hold is absence,
10536        // which counts on every root of a run (the 0.1.12 rule of the
10537        // rungs' absence, now the peers'). This witness's subject is D-M1
10538        // for the other striking classes, so its two faulting roots are
10539        // served unproven over a parent the courier cannot serve and the
10540        // peer answers with a transport fault (`courier`); on 0.1.9 to
10541        // 0.1.14 the peer did not hold them (`not_served`).
10542        for idx in [1usize, 3] {
10543            let parent = child_of(&format!("{:064x}", 0xdead_0000 + idx), 0);
10544            let child = child_of(&parent.id(), 0);
10545            let old = std::mem::replace(
10546                &mut std::rc::Rc::make_mut(&mut remote.outputs)[idx].txid,
10547                child.id(),
10548            );
10549            let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10550            nodes.remove(&old);
10551            nodes.insert(
10552                child.id(),
10553                crate::types::GASPNode {
10554                    graph_id: String::new(),
10555                    raw_tx: child.to_hex(),
10556                    output_index: 0,
10557                    proof: None,
10558                    tx_metadata: None,
10559                    output_metadata: None,
10560                    inputs: None,
10561                },
10562            );
10563            remote.faults_for.borrow_mut().insert(parent.id());
10564        }
10565        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
10566            asked: std::rc::Rc::default(),
10567            transient: std::rc::Rc::default(),
10568        }));
10569        let store = std::rc::Rc::new(MemoryStorage::new());
10570        engine.storage = Box::new(store.clone());
10571        virtual_peer_budget(&mut engine, &remote);
10572        engine.set_graph_budget(
10573            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10574            100,
10575            60_000,
10576        );
10577        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10578        let result = engine.start_gasp_sync().await.unwrap();
10579        let topic = &result.topics_synced["tm_test"];
10580        let named: Vec<(&str, &str, u32)> = topic
10581            .faulted_roots
10582            .iter()
10583            .map(|f| (f.outpoint.as_str(), f.class.as_str(), f.faults))
10584            .collect();
10585        let low = format!("{}.0", remote.outputs[1].txid);
10586        let high = format!("{}.0", remote.outputs[3].txid);
10587        assert_eq!(
10588            named,
10589            vec![
10590                (low.as_str(), crate::gasp::fault_class::COURIER, 1),
10591                (high.as_str(), crate::gasp::fault_class::COURIER, 0),
10592            ]
10593        );
10594        assert_eq!(topic.cursor_moves[0].to, 10, "below the lower gap");
10595        let host = "https://budget.example";
10596        assert!(store
10597            .get_deferred_graph(host, "tm_test", &high)
10598            .await
10599            .unwrap()
10600            .is_none());
10601        assert_eq!(
10602            store
10603                .get_deferred_graph(host, "tm_test", &low)
10604                .await
10605                .unwrap()
10606                .unwrap()
10607                .reason,
10608            "retry:fault:courier"
10609        );
10610    }
10611
10612    /// A record of the budget page's peer and topic, holding nothing but
10613    /// its root (engine 0.1.10 tests).
10614    fn bare_record(n: u32, score: u64, reason: &str, passable: bool) -> crate::gasp::DeferredGraph {
10615        let outpoint = format!("{:064x}.0", 0xdead_0000 + n);
10616        crate::gasp::DeferredGraph {
10617            peer: "https://budget.example".into(),
10618            topic: "tm_test".into(),
10619            outpoint: outpoint.clone(),
10620            score,
10621            nodes: Vec::new(),
10622            pending: vec![crate::gasp::PendingInput {
10623                outpoint: outpoint.clone(),
10624                graph_id: outpoint,
10625                metadata: true,
10626                spent_by: None,
10627                parent_proven: false,
10628            }],
10629            calls: 0,
10630            passes: 0,
10631            reason: reason.into(),
10632            configured: true,
10633            idle_faults: 0,
10634            cursor_passable: passable,
10635            faults: 0,
10636            retry_at: 0,
10637            idle_resumes: 0,
10638            unanchorable_retries: 0,
10639            expiry: None,
10640        }
10641    }
10642
10643    /// Fill the budget page's (peer, topic) with `count` root retry
10644    /// obligations the peer does not serve (fact 3 of the 0.1.10 brief:
10645    /// `held:retry`, nodes 0, below the cursor), returning their roots.
10646    async fn hold_obligations(engine: &Engine, count: usize) -> Vec<String> {
10647        let mut roots = Vec::new();
10648        for n in 0..count {
10649            let record = bare_record(n as u32, 1 + n as u64, "retry", true);
10650            roots.push(record.outpoint.clone());
10651            engine.storage().put_deferred_graph(&record).await.unwrap();
10652        }
10653        roots
10654    }
10655
10656    /// 0.1.10 (room): a (peer, topic) whose deferred slots are all held by
10657    /// root retry obligations that never progress still records the root
10658    /// that holds its cursor: a fault record is bounded on its own
10659    /// (the separate compact memo), so the strike is written on each
10660    /// faulted run and the third passes the cursor. On 0.1.9 every strike
10661    /// found "no room" (`gasp.rs:1717`), the root was named with no run and
10662    /// the cursor stayed at 0 for ever (fact 2: the keeper's eighth hand).
10663    #[tokio::test]
10664    async fn room_0110_a_full_store_still_records_the_cursors_holder_and_passes_it() {
10665        let UnanchorableFixture {
10666            engine,
10667            store,
10668            root,
10669            ..
10670        } = unanchorable_fixture();
10671        let host = "https://budget.example";
10672        let obligations =
10673            hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10674        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10675        for run in 1..=runs {
10676            let result = engine.start_gasp_sync().await.unwrap();
10677            let topic = &result.topics_synced["tm_test"];
10678            assert_eq!(topic.strikes_refused, 0, "run {run}: {topic:?}");
10679            let named = topic
10680                .faulted_roots
10681                .iter()
10682                .find(|f| f.outpoint == root)
10683                .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
10684            assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10685            assert_eq!(named.faults, run, "run {run}: the strike was written");
10686            assert!(named.refused.is_empty(), "{named:?}");
10687            if run < runs {
10688                assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10689                assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10690            } else {
10691                assert!(named.unanchorable);
10692                assert_eq!(topic.unanchorable_graphs, 1);
10693                assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10694                assert_eq!(topic.cursor_moves[0].to, 30, "passed");
10695                assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10696            }
10697        }
10698        // The obligations are all still held beside the fault record.
10699        let held: Vec<String> = store
10700            .deferred_graphs()
10701            .into_iter()
10702            .map(|r| r.outpoint)
10703            .collect();
10704        // 0.1.14: the fault fact no longer consumes a partial-graph row.
10705        assert_eq!(held.len(), obligations.len(), "{held:?}");
10706        assert_eq!(store.fault_memos().len(), 1);
10707        assert!(obligations.iter().all(|o| held.contains(o)));
10708        let record = store
10709            .get_deferred_graph(host, "tm_test", &root)
10710            .await
10711            .unwrap()
10712            .unwrap();
10713        assert_eq!(record.reason, "unanchorable:courier");
10714    }
10715
10716    /// 0.1.10 (the reason, readable): a strike the bound or the storage
10717    /// refuses is counted (`strikes_refused`) and named on the root
10718    /// (`refused`): `no_room` at the storage's own ceiling, `store_fault`
10719    /// on its fault. The compact memo replaces the old 32-fact limit.
10720    /// The root stays a gap the cursor holds below, as on 0.1.9, where the
10721    /// refusal was a `warn!` alone.
10722    #[tokio::test]
10723    async fn room_0110_a_refused_strike_is_counted_and_named() {
10724        let UnanchorableFixture {
10725            engine,
10726            store,
10727            root,
10728            ..
10729        } = unanchorable_fixture();
10730        // 0.1.14: only an actual storage ceiling refuses a compact fact.
10731        store.set_fault_memo_ceiling(Some(0));
10732        let refused_as = |result: &super::GASPSyncResult, why: &str| {
10733            let topic = &result.topics_synced["tm_test"];
10734            assert_eq!(topic.strikes_refused, 1, "{topic:?}");
10735            let named = topic
10736                .faulted_roots
10737                .iter()
10738                .find(|f| f.outpoint == root)
10739                .unwrap_or_else(|| panic!("{topic:?}"));
10740            assert_eq!(named.refused, why, "{named:?}");
10741            assert_eq!(named.faults, 0, "no record holds the count");
10742            assert!(topic.cursor_moves.is_empty(), "the gap holds");
10743            assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10744        };
10745        let result = engine.start_gasp_sync().await.unwrap();
10746        refused_as(&result, crate::gasp::strike_refused::NO_ROOM);
10747        assert!(store
10748            .get_deferred_graph("https://budget.example", "tm_test", &root)
10749            .await
10750            .unwrap()
10751            .is_none());
10752
10753        // The storage faults.
10754        store.set_fault_memo_ceiling(None);
10755        store.set_deferred_graph_put_fault(true);
10756        let result = engine.start_gasp_sync().await.unwrap();
10757        refused_as(&result, crate::gasp::strike_refused::STORE_FAULT);
10758
10759        // The storage serves again: the strike is written.
10760        store.set_deferred_graph_put_fault(false);
10761        let result = engine.start_gasp_sync().await.unwrap();
10762        let topic = &result.topics_synced["tm_test"];
10763        assert_eq!(topic.strikes_refused, 0, "{topic:?}");
10764        assert!(store
10765            .get_deferred_graph("https://budget.example", "tm_test", &root)
10766            .await
10767            .unwrap()
10768            .is_some());
10769    }
10770
10771    /// 0.1.10 (the churn bounded): sixteen root retry obligations the peer
10772    /// no longer serves (fact 3: `held:retry`, nodes 0) took every run's
10773    /// slice on 0.1.9, resumed ahead of the page, appending nothing, so the
10774    /// page's fresh roots were never walked. Each now goes to the long
10775    /// backoff after `IDLE_RESUMES_BEFORE_BACKOFF` idle resumes, is asked
10776    /// nothing while it waits, and stays held; the page is walked.
10777    #[tokio::test]
10778    async fn room_0110_idle_obligations_back_off_and_the_page_is_walked() {
10779        let (mut engine, mut remote) = budget_page_engine(3);
10780        remote.node_cost_ms = 2000;
10781        let store = std::rc::Rc::new(MemoryStorage::new());
10782        engine.storage = Box::new(store.clone());
10783        virtual_peer_budget(&mut engine, &remote);
10784        engine.set_graph_budget(
10785            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10786            100,
10787            60_000,
10788        );
10789        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10790        let obligations =
10791            hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10792        let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10793        // The witness of 0.1.9: each run's slice goes to the obligations
10794        // (15 resumed, nothing appended, the last of them cut in flight by
10795        // the per-peer deadline) and the page is never reached.
10796        for run in 1..=idle {
10797            let result = engine.start_gasp_sync().await.unwrap();
10798            let topic = &result.topics_synced["tm_test"];
10799            assert_eq!(topic.finalized_graphs, 0, "run {run}: {topic:?}");
10800            assert!(topic.cursor_moves.is_empty(), "run {run}");
10801            assert_eq!(topic.resumed_graphs, 15, "run {run}");
10802            assert_eq!(topic.waiting_graphs, 0, "run {run}");
10803            let expect = if run == idle { 15 } else { 0 };
10804            assert_eq!(topic.backed_off_graphs, expect, "run {run}: {topic:?}");
10805        }
10806        // The fifteen wait; the slice reaches the page.
10807        let result = engine.start_gasp_sync().await.unwrap();
10808        let topic = &result.topics_synced["tm_test"];
10809        assert_eq!(topic.waiting_graphs, 15, "{topic:?}");
10810        assert_eq!(topic.finalized_graphs, 3, "the page's fresh roots");
10811        assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10812        assert_eq!(topic.cursor_moves[0].to, 30);
10813        // The last one follows; then every obligation waits, asked nothing.
10814        let mut backed_off = 15;
10815        for _ in 0..idle {
10816            let result = engine.start_gasp_sync().await.unwrap();
10817            backed_off += result.topics_synced["tm_test"].backed_off_graphs;
10818        }
10819        assert_eq!(backed_off, obligations.len() as u64);
10820        let asked = remote.asked.borrow().len();
10821        let result = engine.start_gasp_sync().await.unwrap();
10822        let topic = &result.topics_synced["tm_test"];
10823        assert_eq!(topic.waiting_graphs, obligations.len() as u64, "{topic:?}");
10824        assert_eq!(topic.resumed_graphs, 0);
10825        assert!(topic.dropped_graphs.is_empty(), "{topic:?}");
10826        assert_eq!(remote.asked.borrow().len(), asked, "no request");
10827        // Kept, not deleted (bsv-low #555's resume), each on its backoff.
10828        let now = (current_timestamp_ms() / 1000.0) as u64;
10829        let held = store.deferred_graphs();
10830        assert_eq!(held.len(), obligations.len());
10831        for record in &held {
10832            assert!(obligations.contains(&record.outpoint));
10833            assert!(record.cursor_passable);
10834            assert!(record.idle_resumes >= idle, "{record:?}");
10835            assert!(record.retry_at > now, "{record:?}");
10836        }
10837    }
10838
10839    /// 0.1.10: a record waiting out its idle backoff whose root is
10840    /// remembered expired (0.1.8's memo, keyed by topic: any peer's walk that
10841    /// refused it) is swept at the next sync ahead of the backoff gate, with
10842    /// no request: refused and deleted, counted in `dropped_graphs`
10843    /// (`refused`). A record that holds its root node is refused the same
10844    /// way from that node; one holding nothing is refused at its next
10845    /// resume, when its root is fetched.
10846    #[tokio::test]
10847    async fn room_0110_a_waiting_record_whose_root_expired_is_swept() {
10848        let (mut engine, remote) = budget_page_engine(1);
10849        let store = std::rc::Rc::new(MemoryStorage::new());
10850        engine.storage = Box::new(store.clone());
10851        engine.set_graph_budget(
10852            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10853            100,
10854            60_000,
10855        );
10856        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10857        let now = (current_timestamp_ms() / 1000.0) as u64;
10858        let mut waiting = bare_record(0, 1, "retry", true);
10859        waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10860        waiting.retry_at = now + 3600;
10861        let dead = bare_record(1, 2, "retry", true);
10862        let mut dead_waiting = dead.clone();
10863        dead_waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10864        dead_waiting.retry_at = now + 3600;
10865        for record in [&waiting, &dead_waiting] {
10866            engine.storage().put_deferred_graph(record).await.unwrap();
10867        }
10868        engine
10869            .storage()
10870            .remember_expired_outpoint("tm_test", &dead.outpoint, 1)
10871            .await
10872            .unwrap();
10873        let result = engine.start_gasp_sync().await.unwrap();
10874        let topic = &result.topics_synced["tm_test"];
10875        assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
10876        assert_eq!(
10877            topic
10878                .dropped_graphs
10879                .iter()
10880                .map(|d| (d.outpoint.as_str(), d.reason.as_str()))
10881                .collect::<Vec<_>>(),
10882            vec![(dead.outpoint.as_str(), "refused")]
10883        );
10884        let held: Vec<String> = store
10885            .deferred_graphs()
10886            .into_iter()
10887            .map(|r| r.outpoint)
10888            .collect();
10889        assert_eq!(held, vec![waiting.outpoint.clone()]);
10890        let asked = remote.asked.borrow();
10891        assert!(!asked.iter().any(|t| dead.outpoint.starts_with(t.as_str())));
10892        assert!(!asked
10893            .iter()
10894            .any(|t| waiting.outpoint.starts_with(t.as_str())));
10895    }
10896
10897    /// 0.1.10: a resume that appends a node resets the idle resumes (and
10898    /// any backoff), so a record that progresses is never backed off.
10899    #[tokio::test]
10900    async fn room_0110_a_resume_that_appends_a_node_resets_the_count() {
10901        let UnanchorableFixture {
10902            mut engine,
10903            remote,
10904            store,
10905            root,
10906            ..
10907        } = unanchorable_fixture();
10908        // One node a pass: the root is appended and its walk deferred.
10909        engine.set_graph_budget_limbs(u64::MAX, 1);
10910        engine.set_gasp_remote_factory(Box::new(remote));
10911        let mut record = bare_record(0, 10, "retry", true);
10912        record.outpoint.clone_from(&root);
10913        record.pending[0].outpoint.clone_from(&root);
10914        record.pending[0].graph_id.clone_from(&root);
10915        record.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF - 1;
10916        engine.storage().put_deferred_graph(&record).await.unwrap();
10917        let result = engine.start_gasp_sync().await.unwrap();
10918        let topic = &result.topics_synced["tm_test"];
10919        assert_eq!(topic.resumed_graphs, 1, "{topic:?}");
10920        assert_eq!(topic.backed_off_graphs, 0);
10921        let held = store
10922            .get_deferred_graph("https://budget.example", "tm_test", &root)
10923            .await
10924            .unwrap()
10925            .unwrap();
10926        assert_eq!(held.nodes.len(), 1, "{held:?}");
10927        assert_eq!(held.reason, "nodes");
10928        assert_eq!(held.idle_resumes, 0);
10929        assert_eq!(held.retry_at, 0);
10930    }
10931
10932    /// The courier of the 0.1.11 brief's fact 3: every rung exhausted, the
10933    /// answer `NodeNotFound` ("no raw tx for <txid> (bitails + woc +
10934    /// bananablocks exhausted)", `proof_fetcher.rs`), no proof for a root.
10935    struct ExhaustedCourier {
10936        asked: std::rc::Rc<Cell<u64>>,
10937        /// Answer a fault of the moment instead (a rung's rate limit).
10938        transient: std::rc::Rc<Cell<bool>>,
10939    }
10940
10941    #[async_trait(?Send)]
10942    impl AncestorFetcher for ExhaustedCourier {
10943        async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10944            self.asked.set(self.asked.get() + 1);
10945            if self.transient.get() {
10946                return Err(GASPError::RemoteError(format!(
10947                    "rung rate limited for {txid}"
10948                )));
10949            }
10950            Err(GASPError::NodeNotFound(format!(
10951                "no raw tx for {txid} (bitails + woc + bananablocks exhausted)"
10952            )))
10953        }
10954        async fn verified_proof_for(&self, _: &str) -> Option<String> {
10955            None
10956        }
10957    }
10958
10959    /// The production shape of the 0.1.11 brief (facts 2 and 3): a budget
10960    /// page of `page` proven roots (scores 10, 20, ...) and `count` root
10961    /// retry obligations written by 0.1.8 and 0.1.9 (`reason "retry"`,
10962    /// passable, nodes 0, no `idleResumes`) at scores 1, 2, ..., each a root
10963    /// the peer serves UNPROVEN whose parent no rung serves.
10964    struct ObligationFixture {
10965        engine: Engine,
10966        remote: BudgetPageRemote,
10967        store: std::rc::Rc<MemoryStorage>,
10968        obligations: Vec<String>,
10969        courier: std::rc::Rc<Cell<u64>>,
10970        transient: std::rc::Rc<Cell<bool>>,
10971    }
10972
10973    async fn obligation_fixture(page: u32, count: u32, node_cost_ms: u64) -> ObligationFixture {
10974        let (mut engine, mut remote) = budget_page_engine(page);
10975        remote.node_cost_ms = node_cost_ms;
10976        let mut obligations = Vec::new();
10977        let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10978        let mut records = Vec::new();
10979        for n in 0..count {
10980            let parent = child_of(&format!("{:064x}", 0xbeef_0000 + n), 0);
10981            let child = child_of(&parent.id(), 0);
10982            // Engine 0.1.15: the peer is asked for an input the courier
10983            // cannot serve. This fixture's subject is the courier, so its
10984            // peer answers the parent with a transport fault, which counts
10985            // nothing (#56).
10986            remote.faults_for.borrow_mut().insert(parent.id());
10987            nodes.insert(
10988                child.id(),
10989                crate::types::GASPNode {
10990                    graph_id: String::new(),
10991                    raw_tx: child.to_hex(),
10992                    output_index: 0,
10993                    proof: None,
10994                    tx_metadata: None,
10995                    output_metadata: None,
10996                    inputs: None,
10997                },
10998            );
10999            let mut record = bare_record(n, 1 + u64::from(n), "retry", true);
11000            let outpoint = format!("{}.0", child.id());
11001            record.outpoint.clone_from(&outpoint);
11002            record.pending[0].outpoint.clone_from(&outpoint);
11003            record.pending[0].graph_id.clone_from(&outpoint);
11004            obligations.push(outpoint);
11005            records.push(record);
11006        }
11007        let courier = std::rc::Rc::new(Cell::new(0));
11008        let transient = std::rc::Rc::new(Cell::new(false));
11009        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
11010            asked: courier.clone(),
11011            transient: transient.clone(),
11012        }));
11013        let store = std::rc::Rc::new(MemoryStorage::new());
11014        engine.storage = Box::new(store.clone());
11015        virtual_peer_budget(&mut engine, &remote);
11016        engine.set_graph_budget(
11017            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
11018            100,
11019            60_000,
11020        );
11021        engine.set_gasp_remote_factory(Box::new(remote.clone()));
11022        for record in &records {
11023            // The JSON 0.1.8 and 0.1.9 wrote: no `idleResumes`, no `faults`.
11024            let json = serde_json::to_value(record).unwrap();
11025            assert!(json.get("idleResumes").is_none() && json.get("faults").is_none());
11026            engine.storage().put_deferred_graph(record).await.unwrap();
11027        }
11028        ObligationFixture {
11029            engine,
11030            remote,
11031            store,
11032            obligations,
11033            courier,
11034            transient,
11035        }
11036    }
11037
11038    /// 0.1.11 (a faulted resume is a strike): a held root retry obligation
11039    /// whose root the peer serves and whose parent the courier cannot serve
11040    /// (facts 2 and 3: `retry`, nodes 0, `courier`, `faults 0` on every run
11041    /// of 0.1.10, never struck, never backed off) is struck on its own slot
11042    /// on each run, recorded `unanchorable:courier` at the third, and not
11043    /// resumed inside its backoff; the page's roots behind it are walked in
11044    /// the same run, every run. The obligation stays passable throughout:
11045    /// its cursor had passed it, and a strike never takes that back.
11046    #[tokio::test]
11047    async fn obligations_0111_a_faulted_resume_is_struck_and_the_page_is_walked() {
11048        let ObligationFixture {
11049            engine,
11050            remote,
11051            store,
11052            obligations,
11053            courier,
11054            ..
11055        } = obligation_fixture(3, 1, 0).await;
11056        let host = "https://budget.example";
11057        let held = &obligations[0];
11058        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
11059        for run in 1..=runs {
11060            let result = engine.start_gasp_sync().await.unwrap();
11061            let topic = &result.topics_synced["tm_test"];
11062            let named = topic
11063                .faulted_roots
11064                .iter()
11065                .find(|f| &f.outpoint == held)
11066                .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11067            assert_eq!(named.class, crate::gasp::fault_class::COURIER);
11068            assert!(named.error.contains("exhausted"), "{named:?}");
11069            assert_eq!(named.faults, run, "run {run}: struck on its own slot");
11070            assert!(named.refused.is_empty(), "{named:?}");
11071            assert_eq!(topic.strikes_refused, 0);
11072            assert!(topic.cursor_held_by.is_empty(), "run {run}: {topic:?}");
11073            if run == 1 {
11074                assert_eq!(topic.finalized_graphs, 3, "the page, the same run");
11075                assert_eq!(topic.cursor_moves[0].to, 30);
11076            }
11077            let record = store
11078                .get_deferred_graph(host, "tm_test", held)
11079                .await
11080                .unwrap()
11081                .unwrap();
11082            assert_eq!(record.faults, run, "{record:?}");
11083            assert!(record.cursor_passable, "run {run}: never holds the cursor");
11084            assert!(record.nodes.is_empty());
11085            if run < runs {
11086                assert!(!named.unanchorable);
11087                assert_eq!(record.reason, "retry:fault:courier");
11088                assert_eq!(topic.unanchorable_graphs, 0);
11089            } else {
11090                assert!(named.unanchorable);
11091                assert_eq!(record.reason, "unanchorable:courier");
11092                assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
11093                assert_eq!(topic.unanchorable_graphs, 1, "{topic:?}");
11094            }
11095        }
11096        // Inside its backoff: not resumed, nothing asked of the peer or the
11097        // courier, nothing named, held.
11098        let (asked, couriered) = (remote.asked.borrow().len(), courier.get());
11099        let result = engine.start_gasp_sync().await.unwrap();
11100        let topic = &result.topics_synced["tm_test"];
11101        assert!(topic.faulted_roots.is_empty(), "{topic:?}");
11102        assert_eq!(topic.resumed_graphs, 0);
11103        assert_eq!(remote.asked.borrow().len(), asked);
11104        assert_eq!(courier.get(), couriered);
11105        // 0.1.14: the obligation's fault fact lives outside partial graphs.
11106        assert!(store.deferred_graphs().is_empty());
11107        assert_eq!(store.fault_memos().len(), 1);
11108    }
11109
11110    /// 0.1.11 (the idle count fires): a resumed pass of a held record that
11111    /// asked and appended nothing it kept counts one idle resume whatever
11112    /// its ending, and the sync's accounting says how many records counted
11113    /// (`idle_counted`). Three endings: (a) the pass's own save (a rung's
11114    /// fault of the moment: the record is kept, reason `fault`); (b) a drop
11115    /// whose record is saved again as an obligation (the peer no longer
11116    /// serves the root, `not_held`); (c) the production shape of fact 3 (the
11117    /// root served, appended and thrown away with the drop, the courier
11118    /// exhausted) when its strike is refused at the fault bound, so the drop
11119    /// stands: on 0.1.10 the post-pass count read the sync's appended nodes,
11120    /// which the thrown-away root had raised, and never fired.
11121    #[tokio::test]
11122    async fn obligations_0111_a_resume_that_appends_nothing_counts_on_every_ending() {
11123        let host = "https://budget.example";
11124        // 0.1.14: exercise the same refusal ending through a real storage
11125        // ceiling, because the old per-peer fault bound no longer exists.
11126        let fixture = obligation_fixture(1, 1, 0).await;
11127        fixture.store.set_fault_memo_ceiling(Some(0));
11128        let held = &fixture.obligations[0];
11129        let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
11130        for run in 1..=idle {
11131            let result = fixture.engine.start_gasp_sync().await.unwrap();
11132            let topic = &result.topics_synced["tm_test"];
11133            let record = fixture
11134                .store
11135                .get_deferred_graph(host, "tm_test", held)
11136                .await
11137                .unwrap()
11138                .unwrap();
11139            assert_eq!(record.idle_resumes, run, "run {run}: {record:?} {topic:?}");
11140            assert_eq!(record.reason, "retry", "the drop stands");
11141            assert!(record.cursor_passable && record.nodes.is_empty());
11142            assert_eq!(topic.idle_counted, 1, "run {run}: {topic:?}");
11143            let named = topic
11144                .faulted_roots
11145                .iter()
11146                .find(|f| &f.outpoint == held)
11147                .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11148            assert_eq!(named.refused, crate::gasp::strike_refused::NO_ROOM);
11149            assert_eq!(named.faults, 0);
11150            assert_eq!(topic.strikes_refused, 1);
11151            assert_eq!(topic.backed_off_graphs, u64::from(run == idle));
11152        }
11153        let result = fixture.engine.start_gasp_sync().await.unwrap();
11154        let topic = &result.topics_synced["tm_test"];
11155        assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
11156        assert_eq!(topic.idle_counted, 0);
11157
11158        // (b) A drop: the peer no longer serves the root.
11159        let mut fixture = obligation_fixture(1, 1, 0).await;
11160        let gone = fixture.obligations[0].clone();
11161        std::rc::Rc::make_mut(&mut fixture.remote.nodes).remove(gone.strip_suffix(".0").unwrap());
11162        fixture
11163            .engine
11164            .set_gasp_remote_factory(Box::new(fixture.remote.clone()));
11165        let result = fixture.engine.start_gasp_sync().await.unwrap();
11166        let topic = &result.topics_synced["tm_test"];
11167        assert_eq!(topic.idle_counted, 1, "{topic:?}");
11168        assert_eq!(
11169            topic
11170                .dropped_graphs
11171                .iter()
11172                .map(|d| d.reason.as_str())
11173                .collect::<Vec<_>>(),
11174            vec!["not_held"]
11175        );
11176        let record = fixture
11177            .store
11178            .get_deferred_graph(host, "tm_test", &gone)
11179            .await
11180            .unwrap()
11181            .unwrap();
11182        assert_eq!((record.idle_resumes, record.faults), (1, 0), "{record:?}");
11183
11184        // (a) The pass's own save: a rung's fault of the moment. The pass
11185        // that appends the root progresses; the next asks and appends
11186        // nothing, and its save counts it.
11187        let fixture = obligation_fixture(1, 1, 0).await;
11188        fixture.transient.set(true);
11189        let kept = &fixture.obligations[0];
11190        let result = fixture.engine.start_gasp_sync().await.unwrap();
11191        assert_eq!(result.topics_synced["tm_test"].idle_counted, 0, "progress");
11192        let result = fixture.engine.start_gasp_sync().await.unwrap();
11193        let topic = &result.topics_synced["tm_test"];
11194        assert_eq!(topic.idle_counted, 1, "{topic:?}");
11195        let record = fixture
11196            .store
11197            .get_deferred_graph(host, "tm_test", kept)
11198            .await
11199            .unwrap()
11200            .unwrap();
11201        assert_eq!(record.reason, "fault");
11202        assert_eq!(
11203            (record.nodes.len(), record.idle_resumes),
11204            (1, 1),
11205            "{record:?}"
11206        );
11207    }
11208
11209    /// 0.1.11 (the page's share): fourteen obligations of fact 3's shape
11210    /// ahead of a page of twelve fresh roots, every request 1,500 ms of a
11211    /// virtual 30,000 ms per-peer slice and a 15,000 ms graph budget. The
11212    /// resumes take at most `resume_share_ms(30_000, 15_000)` = 9,900 ms
11213    /// together; those reached after it are held back (not resumed, not a
11214    /// failure, not stalled, not idle, their records as they were) and the
11215    /// page's twelve roots are all walked and finalized in the same run.
11216    /// On 0.1.10 the resumes shared one graph budget (15,000 ms): ten of
11217    /// them ran, and the per-peer deadline dropped the sync at nine of the
11218    /// page's twelve roots (fact 4: the page got what the resumes left).
11219    #[tokio::test]
11220    async fn obligations_0111_fourteen_obligations_leave_the_page_its_share() {
11221        let ObligationFixture {
11222            mut engine,
11223            remote,
11224            store,
11225            obligations,
11226            ..
11227        } = obligation_fixture(12, 14, 1500).await;
11228        let elapsed = remote.elapsed.clone();
11229        engine.set_graph_budget(
11230            std::rc::Rc::new(move |ms| {
11231                let elapsed = elapsed.clone();
11232                let until = elapsed.get() + ms;
11233                Box::pin(std::future::poll_fn(move |_| {
11234                    if elapsed.get() >= until {
11235                        std::task::Poll::Ready(())
11236                    } else {
11237                        std::task::Poll::Pending
11238                    }
11239                }))
11240            }),
11241            100,
11242            15_000,
11243        );
11244        assert_eq!(crate::gasp::resume_share_ms(30_000, 15_000), 9_900);
11245        let result = engine.start_gasp_sync().await.unwrap();
11246        let topic = &result.topics_synced["tm_test"];
11247        assert!(topic.errors.is_empty(), "inside the slice: {topic:?}");
11248        assert_eq!(topic.finalized_graphs, 12, "the page's share: {topic:?}");
11249        assert_eq!(topic.cursor_moves[0].to, 120);
11250        // Three resumes struck; the fourth cut in flight by the share, having
11251        // asked and appended nothing (counted idle and stalled, as 0.1.10
11252        // counts a deadline's cut); the other ten held back. Engine 0.1.15:
11253        // each resume asks the peer for the parent the courier cannot serve
11254        // (#56), two requests of 1,500 ms, so the 9,900 ms share holds three
11255        // (six on 0.1.11 to 0.1.14, one request each).
11256        assert_eq!(topic.resumed_graphs, 4, "{topic:?}");
11257        assert_eq!(topic.held_back_graphs, 10, "{topic:?}");
11258        assert_eq!(topic.faulted_roots.len(), 3, "{topic:?}");
11259        assert_eq!((topic.stalled_graphs, topic.idle_counted), (1, 1));
11260        assert!(topic.cursor_held_by.is_empty());
11261        let host = "https://budget.example";
11262        let mut reasons = std::collections::BTreeMap::new();
11263        for outpoint in &obligations {
11264            let record = store
11265                .get_deferred_graph(host, "tm_test", outpoint)
11266                .await
11267                .unwrap()
11268                .unwrap();
11269            assert!(record.cursor_passable, "{record:?}");
11270            if record.reason == "retry" {
11271                // Held back: as it was, not counted idle.
11272                assert_eq!((record.idle_resumes, record.retry_at), (0, 0));
11273                assert_eq!((record.faults, record.passes), (0, 0));
11274            }
11275            *reasons.entry(record.reason).or_insert(0) += 1;
11276        }
11277        assert_eq!(
11278            reasons,
11279            std::collections::BTreeMap::from([
11280                ("retry".to_string(), 10),
11281                ("retry:fault:courier".to_string(), 3),
11282                ("time".to_string(), 1),
11283            ])
11284        );
11285    }
11286
11287    #[tokio::test]
11288    async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
11289        let (mut engine, mut remote) = budget_page_engine(1);
11290        let txid = remote.outputs[0].txid.clone();
11291        std::rc::Rc::make_mut(&mut remote.nodes)
11292            .get_mut(&txid)
11293            .unwrap()
11294            .proof = None;
11295        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
11296            proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
11297            requested: std::rc::Rc::default(),
11298        }));
11299        virtual_peer_budget(&mut engine, &remote);
11300        engine.set_gasp_remote_factory(Box::new(remote));
11301        let result = engine.start_gasp_sync().await.unwrap();
11302        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
11303        assert!(engine
11304            .storage
11305            .find_output(&txid, 0, Some("tm_test"), None, true)
11306            .await
11307            .unwrap()
11308            .is_none());
11309    }
11310
11311    impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
11312        fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11313            Box::new(MockSyncRemote {
11314                utxos: vec![
11315                    crate::types::GASPOutput {
11316                        txid: "remote_tx1".to_string(),
11317                        output_index: 0,
11318                        score: 100.0,
11319                    },
11320                    crate::types::GASPOutput {
11321                        txid: "remote_tx2".to_string(),
11322                        output_index: 0,
11323                        score: 200.0,
11324                    },
11325                ],
11326            })
11327        }
11328    }
11329
11330    #[tokio::test]
11331    async fn test_start_gasp_sync_with_factory_runs_sync() {
11332        let mut sync_config: SyncConfiguration = HashMap::new();
11333        sync_config.insert(
11334            "tm_test".to_string(),
11335            SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
11336        );
11337
11338        let mut engine = Engine::new(
11339            HashMap::new(),
11340            HashMap::new(),
11341            Box::new(MemoryStorage::new()),
11342            None,
11343            EngineConfig {
11344                sync_configuration: sync_config,
11345                ..Default::default()
11346            },
11347        );
11348
11349        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11350
11351        let result = engine.start_gasp_sync().await.unwrap();
11352        assert_eq!(result.topics_synced.len(), 1);
11353
11354        let topic_result = &result.topics_synced["tm_test"];
11355        assert_eq!(topic_result.peers.len(), 1);
11356        assert!(
11357            topic_result.errors.is_empty(),
11358            "Sync should succeed with mock remote"
11359        );
11360
11361        // Verify last_interaction was persisted
11362        let last = engine
11363            .storage()
11364            .get_last_interaction("https://peer1.example.com", "tm_test")
11365            .await
11366            .unwrap();
11367        // Synthetic nodes cannot assemble into BEEF. No score completed,
11368        // so the entry cursor stays and the next sync re-requests both.
11369        assert_eq!(
11370            last, 0,
11371            "cursor must remain at entry when no output completed"
11372        );
11373    }
11374
11375    #[tokio::test]
11376    async fn test_start_gasp_sync_with_factory_handles_error() {
11377        struct FailingFactory;
11378
11379        impl crate::gasp::GASPRemoteFactory for FailingFactory {
11380            fn create_remote(
11381                &self,
11382                _peer_url: &str,
11383                _topic: &str,
11384            ) -> Box<dyn crate::gasp::GASPRemote> {
11385                struct FailingRemote;
11386                #[async_trait(?Send)]
11387                impl crate::gasp::GASPRemote for FailingRemote {
11388                    async fn get_initial_response(
11389                        &self,
11390                        _: &crate::types::GASPInitialRequest,
11391                    ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
11392                    {
11393                        Err(crate::gasp::GASPError::RemoteError(
11394                            "connection refused".into(),
11395                        ))
11396                    }
11397                    async fn get_initial_reply(
11398                        &self,
11399                        _: &crate::types::GASPInitialResponse,
11400                    ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
11401                    {
11402                        unreachable!()
11403                    }
11404                    async fn request_node(
11405                        &self,
11406                        _: &str,
11407                        _: &str,
11408                        _: u32,
11409                        _: bool,
11410                    ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
11411                    {
11412                        unreachable!()
11413                    }
11414                    async fn submit_node(
11415                        &self,
11416                        _: &crate::types::GASPNode,
11417                    ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
11418                    {
11419                        unreachable!()
11420                    }
11421                }
11422                Box::new(FailingRemote)
11423            }
11424        }
11425
11426        let mut sync_config: SyncConfiguration = HashMap::new();
11427        sync_config.insert(
11428            "tm_test".to_string(),
11429            SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
11430        );
11431
11432        let mut engine = Engine::new(
11433            HashMap::new(),
11434            HashMap::new(),
11435            Box::new(MemoryStorage::new()),
11436            None,
11437            EngineConfig {
11438                sync_configuration: sync_config,
11439                ..Default::default()
11440            },
11441        );
11442
11443        engine.set_gasp_remote_factory(Box::new(FailingFactory));
11444
11445        let result = engine.start_gasp_sync().await.unwrap();
11446        assert_eq!(result.topics_synced.len(), 1);
11447
11448        let topic_result = &result.topics_synced["tm_test"];
11449        assert_eq!(
11450            topic_result.errors.len(),
11451            1,
11452            "Should have one error for the failing peer"
11453        );
11454        assert!(topic_result.errors[0].contains("connection refused"));
11455    }
11456
11457    #[tokio::test]
11458    async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
11459        let mut sync_config: SyncConfiguration = HashMap::new();
11460        sync_config.insert(
11461            "tm_test".to_string(),
11462            SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
11463        );
11464
11465        // No factory set — should still work (just peer discovery, no sync)
11466        let engine = Engine::new(
11467            HashMap::new(),
11468            HashMap::new(),
11469            Box::new(MemoryStorage::new()),
11470            None,
11471            EngineConfig {
11472                sync_configuration: sync_config,
11473                ..Default::default()
11474            },
11475        );
11476
11477        let result = engine.start_gasp_sync().await.unwrap();
11478        assert_eq!(result.topics_synced.len(), 1);
11479
11480        let topic_result = &result.topics_synced["tm_test"];
11481        assert_eq!(topic_result.peers.len(), 1);
11482        assert!(topic_result.errors.is_empty());
11483    }
11484
11485    #[tokio::test]
11486    async fn test_start_gasp_sync_with_factory_multiple_peers() {
11487        let mut sync_config: SyncConfiguration = HashMap::new();
11488        sync_config.insert(
11489            "tm_test".to_string(),
11490            SyncTarget::Peers(vec![
11491                "https://peer1.example.com".to_string(),
11492                "https://peer2.example.com".to_string(),
11493            ]),
11494        );
11495
11496        let mut engine = Engine::new(
11497            HashMap::new(),
11498            HashMap::new(),
11499            Box::new(MemoryStorage::new()),
11500            None,
11501            EngineConfig {
11502                sync_configuration: sync_config,
11503                ..Default::default()
11504            },
11505        );
11506
11507        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11508
11509        let result = engine.start_gasp_sync().await.unwrap();
11510        let topic_result = &result.topics_synced["tm_test"];
11511        assert_eq!(topic_result.peers.len(), 2);
11512        assert!(topic_result.errors.is_empty());
11513
11514        // Neither peer completed its synthetic graphs.
11515        let last1 = engine
11516            .storage()
11517            .get_last_interaction("https://peer1.example.com", "tm_test")
11518            .await
11519            .unwrap();
11520        let last2 = engine
11521            .storage()
11522            .get_last_interaction("https://peer2.example.com", "tm_test")
11523            .await
11524            .unwrap();
11525        assert_eq!(last1, 0);
11526        assert_eq!(last2, 0);
11527    }
11528
11529    // ── Per-peer budget + dead-peer quarantine (bsv-low#302) ──────────
11530
11531    /// A remote whose initial request HANGS forever — models the dead
11532    /// ephemeral peers (ngrok tunnels) whose fetch never returns, the #257
11533    /// root cause the per-peer budget exists for.
11534    struct HangingRemote;
11535
11536    #[async_trait(?Send)]
11537    impl crate::gasp::GASPRemote for HangingRemote {
11538        async fn get_initial_response(
11539            &self,
11540            _: &crate::types::GASPInitialRequest,
11541        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
11542            std::future::pending::<()>().await;
11543            unreachable!()
11544        }
11545        async fn get_initial_reply(
11546            &self,
11547            _: &crate::types::GASPInitialResponse,
11548        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
11549            unreachable!()
11550        }
11551        async fn request_node(
11552            &self,
11553            _: &str,
11554            _: &str,
11555            _: u32,
11556            _: bool,
11557        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
11558            unreachable!()
11559        }
11560        async fn submit_node(
11561            &self,
11562            _: &crate::types::GASPNode,
11563        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
11564            unreachable!()
11565        }
11566    }
11567
11568    /// Factory that HANGS for `hang.example.com` peers and serves the normal
11569    /// mock sync remote for everyone else, counting `create_remote` calls
11570    /// (a quarantine-skipped peer never reaches the factory).
11571    struct SelectiveHangFactory {
11572        created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
11573    }
11574
11575    impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
11576        fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11577            self.created.borrow_mut().push(peer_url.to_string());
11578            if peer_url.contains("hang.example.com") {
11579                Box::new(HangingRemote)
11580            } else {
11581                Box::new(MockSyncRemote {
11582                    utxos: vec![crate::types::GASPOutput {
11583                        txid: "remote_tx1".to_string(),
11584                        output_index: 0,
11585                        score: 100.0,
11586                    }],
11587                })
11588            }
11589        }
11590    }
11591
11592    /// The deterministic "instant deadline" sleep factory: `race_or_deadline`
11593    /// polls the sync future FIRST, so an in-memory sync that never yields
11594    /// still completes; only a future that actually returns Pending (the
11595    /// hanging remote) loses the race.
11596    fn instant_deadline() -> crate::engine::SleepFactory {
11597        std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
11598    }
11599
11600    fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
11601        let mut sync_config: SyncConfiguration = HashMap::new();
11602        sync_config.insert(
11603            "tm_test".to_string(),
11604            SyncTarget::Peers(vec![
11605                "https://hang.example.com".to_string(),
11606                "https://good.example.com".to_string(),
11607            ]),
11608        );
11609        Engine::new(
11610            HashMap::new(),
11611            HashMap::new(),
11612            Box::new(store),
11613            None,
11614            EngineConfig {
11615                sync_configuration: sync_config,
11616                ..Default::default()
11617            },
11618        )
11619    }
11620
11621    #[tokio::test]
11622    async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
11623        let store = std::rc::Rc::new(MemoryStorage::new());
11624        let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11625        let mut engine = two_peer_engine(store.clone());
11626        engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11627            created: created.clone(),
11628        }));
11629        engine.set_peer_sync_budget(instant_deadline(), 1);
11630
11631        let result = engine.start_gasp_sync().await.unwrap();
11632        let topic_result = &result.topics_synced["tm_test"];
11633
11634        // The hanging peer was dropped LOUDLY (an error entry) and the loop
11635        // CONTINUED: the good peer was still attempted and completed.
11636        assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
11637        assert!(
11638            topic_result.errors[0].contains("budget"),
11639            "{:?}",
11640            topic_result.errors
11641        );
11642        assert_eq!(
11643            created.borrow().as_slice(),
11644            ["https://hang.example.com", "https://good.example.com"],
11645            "both peers attempted this tick"
11646        );
11647
11648        // A timed-out peer stays at entry. The other peer was reached but
11649        // served synthetic graphs that cannot complete, so its cursor stays too.
11650        let hang_cursor = store
11651            .get_last_interaction("https://hang.example.com", "tm_test")
11652            .await
11653            .unwrap();
11654        assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
11655        let good_cursor = store
11656            .get_last_interaction("https://good.example.com", "tm_test")
11657            .await
11658            .unwrap();
11659        assert_eq!(good_cursor, 0, "no synthetic graph completed");
11660
11661        // Health bookkeeping: timeout = failure, completion = success.
11662        let hang_health = store
11663            .get_peer_sync_health("hang.example.com", "tm_test")
11664            .await
11665            .unwrap();
11666        assert_eq!(hang_health.consecutive_failures, 1);
11667        let good_health = store
11668            .get_peer_sync_health("good.example.com", "tm_test")
11669            .await
11670            .unwrap();
11671        assert_eq!(good_health.consecutive_failures, 0);
11672    }
11673
11674    #[tokio::test]
11675    async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
11676        let store = std::rc::Rc::new(MemoryStorage::new());
11677        let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11678
11679        let mut sync_config: SyncConfiguration = HashMap::new();
11680        sync_config.insert(
11681            "tm_test".to_string(),
11682            SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
11683        );
11684        let mut engine = Engine::new(
11685            HashMap::new(),
11686            HashMap::new(),
11687            Box::new(store.clone()),
11688            None,
11689            EngineConfig {
11690                sync_configuration: sync_config,
11691                ..Default::default()
11692            },
11693        );
11694        engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11695            created: created.clone(),
11696        }));
11697        engine.set_peer_sync_budget(instant_deadline(), 1);
11698
11699        // Fail the peer up to (but not past) the quarantine threshold — every
11700        // tick attempts it (advance the clock between ticks so the attempts
11701        // are distinguishable in age).
11702        for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
11703            engine.start_gasp_sync().await.unwrap();
11704            store.advance_clock(900);
11705            assert_eq!(
11706                created.borrow().len() as u64,
11707                i + 1,
11708                "peer attempted while below the threshold"
11709            );
11710        }
11711        let health = store
11712            .get_peer_sync_health("hang.example.com", "tm_test")
11713            .await
11714            .unwrap();
11715        assert_eq!(
11716            health.consecutive_failures,
11717            crate::gasp::PEER_QUARANTINE_THRESHOLD
11718        );
11719
11720        // Next tick: QUARANTINED — the factory is never consulted, no error
11721        // is emitted, and the attempt count does not grow (a skip is not an
11722        // attempt, so the re-probe window keeps aging).
11723        let result = engine.start_gasp_sync().await.unwrap();
11724        assert!(result.topics_synced["tm_test"].errors.is_empty());
11725        assert_eq!(
11726            created.borrow().len() as u64,
11727            crate::gasp::PEER_QUARANTINE_THRESHOLD,
11728            "quarantined peer must be skipped"
11729        );
11730
11731        // Age past the re-probe window: exactly one fresh probe is allowed.
11732        store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11733        engine.start_gasp_sync().await.unwrap();
11734        assert_eq!(
11735            created.borrow().len() as u64,
11736            crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
11737            "re-probe attempted after the window"
11738        );
11739        // The probe failed again → failure count kept growing → re-armed.
11740        let health = store
11741            .get_peer_sync_health("hang.example.com", "tm_test")
11742            .await
11743            .unwrap();
11744        assert_eq!(
11745            health.consecutive_failures,
11746            crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
11747        );
11748        let result = engine.start_gasp_sync().await.unwrap();
11749        assert!(
11750            result.topics_synced["tm_test"].errors.is_empty(),
11751            "re-quarantined"
11752        );
11753
11754        // The peer comes back to life: age to the next re-probe, then answer
11755        // like a healthy peer → SUCCESS resets the count to 0 (full
11756        // re-admission — quarantine is never a deletion).
11757        store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11758        // Swap the factory for one that serves the healthy mock for this URL.
11759        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11760        let result = engine.start_gasp_sync().await.unwrap();
11761        assert!(result.topics_synced["tm_test"].errors.is_empty());
11762        let health = store
11763            .get_peer_sync_health("hang.example.com", "tm_test")
11764            .await
11765            .unwrap();
11766        assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
11767    }
11768
11769    // ── Mock ARC Broadcaster ─────────────────────────────────────────
11770
11771    use crate::broadcaster::ArcBroadcaster;
11772
11773    struct MockArcBroadcaster {
11774        calls: Arc<Mutex<Vec<String>>>,
11775        should_fail: bool,
11776    }
11777
11778    impl MockArcBroadcaster {
11779        fn new() -> Self {
11780            Self {
11781                calls: Arc::new(Mutex::new(Vec::new())),
11782                should_fail: false,
11783            }
11784        }
11785
11786        fn failing() -> Self {
11787            Self {
11788                calls: Arc::new(Mutex::new(Vec::new())),
11789                should_fail: true,
11790            }
11791        }
11792    }
11793
11794    #[async_trait(?Send)]
11795    impl ArcBroadcaster for MockArcBroadcaster {
11796        async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
11797            self.calls.lock().unwrap().push(raw_tx_hex.to_string());
11798            if self.should_fail {
11799                Err("mock ARC failure".to_string())
11800            } else {
11801                Ok("mock_txid_from_arc".to_string())
11802            }
11803        }
11804    }
11805
11806    /// Create engine with an ARC broadcaster (and optionally a SHIP broadcaster).
11807    fn make_engine_with_arc(
11808        admit_indices: Vec<u32>,
11809        arc: MockArcBroadcaster,
11810    ) -> (Engine, Arc<Mutex<Vec<String>>>) {
11811        let arc_calls = arc.calls.clone();
11812
11813        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11814        managers.insert(
11815            "tm_test".to_string(),
11816            Box::new(MockTopicManager::admitting(admit_indices)),
11817        );
11818
11819        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
11820        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
11821
11822        let storage = Box::new(MemoryStorage::new());
11823
11824        let engine = Engine::with_all(
11825            managers,
11826            lookup_services,
11827            storage,
11828            None,
11829            None,
11830            Some(Box::new(arc)),
11831            None,
11832            EngineConfig::default(),
11833        );
11834
11835        (engine, arc_calls)
11836    }
11837
11838    // ── ARC Broadcaster Tests ────────────────────────────────────────
11839
11840    #[tokio::test]
11841    async fn test_arc_broadcaster_called_on_current_tx() {
11842        let arc = MockArcBroadcaster::new();
11843        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11844
11845        let beef = test_tagged_beef(vec!["tm_test"]);
11846        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11847
11848        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11849
11850        let calls = arc_calls.lock().unwrap();
11851        assert_eq!(
11852            calls.len(),
11853            1,
11854            "ARC broadcaster should be called once for CurrentTx"
11855        );
11856        assert!(
11857            !calls[0].is_empty(),
11858            "ARC should receive non-empty raw tx hex"
11859        );
11860    }
11861
11862    #[tokio::test]
11863    async fn test_arc_broadcaster_not_called_on_historical_tx() {
11864        let arc = MockArcBroadcaster::new();
11865        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11866
11867        let beef = test_tagged_beef(vec!["tm_test"]);
11868        engine
11869            .submit(&beef, SubmitMode::HistoricalTx)
11870            .await
11871            .unwrap();
11872
11873        assert_eq!(
11874            arc_calls.lock().unwrap().len(),
11875            0,
11876            "ARC broadcaster should NOT be called for HistoricalTx"
11877        );
11878    }
11879
11880    #[tokio::test]
11881    async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
11882        let arc = MockArcBroadcaster::new();
11883        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11884
11885        let beef = test_tagged_beef(vec!["tm_test"]);
11886        engine
11887            .submit(&beef, SubmitMode::HistoricalTxNoSpv)
11888            .await
11889            .unwrap();
11890
11891        assert_eq!(
11892            arc_calls.lock().unwrap().len(),
11893            0,
11894            "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
11895        );
11896    }
11897
11898    #[tokio::test]
11899    async fn test_arc_broadcast_failure_does_not_fail_submit() {
11900        let arc = MockArcBroadcaster::failing();
11901        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11902
11903        let beef = test_tagged_beef(vec!["tm_test"]);
11904        // submit should succeed even when ARC fails
11905        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11906
11907        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11908
11909        // ARC was called (and failed), but submit still succeeded
11910        assert_eq!(
11911            arc_calls.lock().unwrap().len(),
11912            1,
11913            "ARC broadcaster should be attempted even if it will fail"
11914        );
11915    }
11916
11917    #[tokio::test]
11918    async fn test_arc_broadcaster_not_present_still_works() {
11919        // Engine without ARC broadcaster — should work fine
11920        let engine = make_engine(vec![0]);
11921
11922        let beef = test_tagged_beef(vec!["tm_test"]);
11923        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11924
11925        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11926    }
11927
11928    // ── Sync advertisements URL validation ─────────────────────────────
11929
11930    /// Mock Advertiser that tracks created advertisements via shared state.
11931    struct TrackingAdvertiser {
11932        created: Arc<Mutex<Vec<AdvertisementData>>>,
11933        existing_ship: Vec<Advertisement>,
11934        existing_slap: Vec<Advertisement>,
11935        /// Topics the returned TaggedBEEF is tagged with — a topic with no
11936        /// registered manager forces the LOCAL submit to fail (#320 3a);
11937        /// several topics exercise independent per-topic admission (D1).
11938        tag_topics: Vec<String>,
11939        /// Simulate a create_advertisements failure (#320 3a).
11940        create_fails: bool,
11941        /// Simulate a find_all_advertisements failure (#320 M2).
11942        find_fails: bool,
11943        /// Return the v1 no-op EMPTY TaggedBEEF from revoke (#320 L2).
11944        revoke_empty: bool,
11945    }
11946
11947    impl TrackingAdvertiser {
11948        fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
11949            let created = Arc::new(Mutex::new(Vec::new()));
11950            (
11951                Self {
11952                    created: created.clone(),
11953                    existing_ship: vec![],
11954                    existing_slap: vec![],
11955                    tag_topics: vec!["tm_test".to_string()],
11956                    create_fails: false,
11957                    find_fails: false,
11958                    revoke_empty: false,
11959                },
11960                created,
11961            )
11962        }
11963    }
11964
11965    use crate::advertiser::{Advertiser, AdvertiserError};
11966
11967    #[async_trait(?Send)]
11968    impl Advertiser for TrackingAdvertiser {
11969        async fn create_advertisements(
11970            &self,
11971            ads: &[AdvertisementData],
11972        ) -> Result<TaggedBEEF, AdvertiserError> {
11973            if self.create_fails {
11974                return Err(AdvertiserError::CreationFailed(
11975                    "mock create refused".into(),
11976                ));
11977            }
11978            self.created.lock().unwrap().extend(ads.iter().cloned());
11979            Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
11980        }
11981
11982        async fn find_all_advertisements(
11983            &self,
11984            protocol: Protocol,
11985        ) -> Result<Vec<Advertisement>, AdvertiserError> {
11986            if self.find_fails {
11987                return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
11988            }
11989            match protocol {
11990                Protocol::Ship => Ok(self.existing_ship.clone()),
11991                Protocol::Slap => Ok(self.existing_slap.clone()),
11992            }
11993        }
11994
11995        async fn revoke_advertisements(
11996            &self,
11997            _ads: &[Advertisement],
11998        ) -> Result<TaggedBEEF, AdvertiserError> {
11999            if self.revoke_empty {
12000                return Ok(TaggedBEEF::new(vec![], vec![]));
12001            }
12002            Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
12003        }
12004
12005        fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
12006            None
12007        }
12008    }
12009
12010    /// TS: "Sync advertisements URL validation"
12011    /// sync_advertisements should be a no-op when hosting_url is empty string,
12012    /// and should skip suppressed topics (tm_ship/tm_slap) when configured.
12013    #[tokio::test]
12014    async fn test_sync_advertisements_url_validation() {
12015        // Case 1: Empty hosting URL — should return early without creating ads
12016        let (adv, created) = TrackingAdvertiser::new();
12017
12018        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12019        managers.insert(
12020            "tm_test".to_string(),
12021            Box::new(MockTopicManager::admitting(vec![0])),
12022        );
12023
12024        let engine = Engine::new(
12025            managers,
12026            HashMap::new(),
12027            Box::new(MemoryStorage::new()),
12028            Some(Box::new(adv)),
12029            EngineConfig {
12030                hosting_url: Some(String::new()), // empty string — invalid
12031                ..Default::default()
12032            },
12033        );
12034
12035        engine.sync_advertisements().await.unwrap();
12036        assert!(
12037            created.lock().unwrap().is_empty(),
12038            "Empty hosting URL should skip advertisement creation"
12039        );
12040
12041        // Case 2: Valid hosting URL — should create SHIP advertisements for non-suppressed topics
12042        let (adv2, created2) = TrackingAdvertiser::new();
12043
12044        let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12045        managers2.insert(
12046            "tm_test".to_string(),
12047            Box::new(MockTopicManager::admitting(vec![0])),
12048        );
12049        // Also register tm_ship to verify it gets suppressed
12050        managers2.insert(
12051            "tm_ship".to_string(),
12052            Box::new(MockTopicManager::admitting(vec![])),
12053        );
12054
12055        let engine2 = Engine::new(
12056            managers2,
12057            HashMap::new(),
12058            Box::new(MemoryStorage::new()),
12059            Some(Box::new(adv2)),
12060            EngineConfig {
12061                hosting_url: Some("https://valid.example.com".to_string()),
12062                suppress_default_sync_advertisements: true,
12063                ..Default::default()
12064            },
12065        );
12066
12067        engine2.sync_advertisements().await.unwrap();
12068        let ads = created2.lock().unwrap();
12069        // tm_ship should be suppressed, only tm_test should get an advertisement
12070        assert!(
12071            ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
12072            "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
12073        );
12074        assert!(
12075            ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
12076            "tm_test should get a SHIP advertisement"
12077        );
12078    }
12079
12080    // ── sync_advertisements report (bsv-low #320 defect 3a) ────────────
12081    //
12082    // Pre-#320 the engine returned `Ok(())` unconditionally: a failed
12083    // create or a failed LOCAL submit was `error!`-logged and invisible to
12084    // the caller — the admin route said `success` while the node's own
12085    // ls_ship/ls_slap never gained its ads, so every cycle re-created the
12086    // whole set. These cells pin the report contract.
12087
12088    fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
12089        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12090        managers.insert(
12091            "tm_test".to_string(),
12092            Box::new(MockTopicManager::admitting(vec![0])),
12093        );
12094        Engine::new(
12095            managers,
12096            HashMap::new(),
12097            Box::new(MemoryStorage::new()),
12098            Some(Box::new(adv)),
12099            EngineConfig {
12100                hosting_url: Some("https://valid.example.com".to_string()),
12101                suppress_default_sync_advertisements: true,
12102                ..Default::default()
12103            },
12104        )
12105    }
12106
12107    #[tokio::test]
12108    async fn sync_report_counts_admitted_outputs_on_success() {
12109        let (adv, _) = TrackingAdvertiser::new();
12110        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12111        assert!(report.ok(), "clean run must report ok: {report:?}");
12112        assert_eq!(report.to_create, 1, "tm_test was missing");
12113        assert_eq!(
12114            report.admitted.get("tm_test"),
12115            Some(&1),
12116            "the STEAK's admitted count must be surfaced: {report:?}"
12117        );
12118    }
12119
12120    #[tokio::test]
12121    async fn sync_report_surfaces_local_submit_failure() {
12122        // The TaggedBEEF comes back tagged for a topic with no registered
12123        // manager → the LOCAL submit fails. That failure must be in the
12124        // report, not swallowed (the live silent-failure shape).
12125        let (mut adv, _) = TrackingAdvertiser::new();
12126        adv.tag_topics = vec!["tm_unregistered".to_string()];
12127        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12128        assert!(!report.ok(), "a failed local submit must not report ok");
12129        assert_eq!(report.to_create, 1);
12130        let submit_error = report.submit_error.as_deref().unwrap_or_default();
12131        assert!(
12132            submit_error.contains("tm_unregistered"),
12133            "verbatim engine error expected, got: {submit_error}"
12134        );
12135        assert!(report.admitted.is_empty());
12136    }
12137
12138    #[tokio::test]
12139    async fn sync_report_surfaces_create_failure() {
12140        let (mut adv, _) = TrackingAdvertiser::new();
12141        adv.create_fails = true;
12142        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12143        assert!(!report.ok());
12144        assert!(
12145            report
12146                .create_error
12147                .as_deref()
12148                .unwrap_or_default()
12149                .contains("mock create refused"),
12150            "{report:?}"
12151        );
12152        assert!(report.submit_error.is_none(), "create failed before submit");
12153    }
12154
12155    #[tokio::test]
12156    async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
12157        // A node whose own storage already lists its ads must attempt ZERO
12158        // creates — the convergence direction of #320 defect 3 (live, the
12159        // missing self-admission made every cycle re-create all 17 ads).
12160        let (mut adv, created) = TrackingAdvertiser::new();
12161        adv.existing_ship = vec![Advertisement {
12162            protocol: Protocol::Ship,
12163            identity_key: "02aa".to_string(),
12164            domain: "https://valid.example.com".to_string(),
12165            topic_or_service: "tm_test".to_string(),
12166            beef: None,
12167            output_index: None,
12168        }];
12169        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12170        assert!(report.ok(), "{report:?}");
12171        assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
12172        assert!(created.lock().unwrap().is_empty());
12173        assert!(
12174            report.effective(),
12175            "converged-noop is the one zero-admit shape that IS success"
12176        );
12177    }
12178
12179    #[tokio::test]
12180    async fn sync_report_zero_admit_is_ok_but_not_effective() {
12181        // #320 M1: the local submit SUCCEEDS but the topic manager admits
12182        // ZERO outputs — a non-error refusal. The ads never enter our own
12183        // ls_ship/ls_slap, so the diff re-creates (re-pays) the full set
12184        // next cycle. `ok()` (errors-only) stays true; `effective()` must
12185        // refuse, and the route keys success on `effective()`.
12186        let (adv, _) = TrackingAdvertiser::new();
12187        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12188        managers.insert(
12189            "tm_test".to_string(),
12190            Box::new(MockTopicManager::admitting(vec![])), // refuses everything
12191        );
12192        let engine = Engine::new(
12193            managers,
12194            HashMap::new(),
12195            Box::new(MemoryStorage::new()),
12196            Some(Box::new(adv)),
12197            EngineConfig {
12198                hosting_url: Some("https://valid.example.com".to_string()),
12199                suppress_default_sync_advertisements: true,
12200                ..Default::default()
12201            },
12202        );
12203        let report = engine.sync_advertisements().await.unwrap();
12204        assert!(report.ok(), "no ERROR occurred: {report:?}");
12205        assert_eq!(report.to_create, 1);
12206        assert_eq!(report.admitted_total(), 0, "{report:?}");
12207        assert!(
12208            !report.effective(),
12209            "zero admitted on a non-empty create must not read as success: {report:?}"
12210        );
12211    }
12212
12213    #[tokio::test]
12214    async fn sync_report_partial_admit_is_not_effective() {
12215        // Delta D1: TWO topics in one submitted tx, per-topic admission
12216        // independent — tm_test admits its output, tm_refuse refuses all.
12217        // admitted_total (1) > 0 but < to_create (2): the refused ads
12218        // would be re-created (re-paid) every cycle, so this must NOT
12219        // read as success.
12220        let (mut adv, _) = TrackingAdvertiser::new();
12221        adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
12222        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12223        managers.insert(
12224            "tm_test".to_string(),
12225            Box::new(MockTopicManager::admitting(vec![0])),
12226        );
12227        managers.insert(
12228            "tm_refuse".to_string(),
12229            Box::new(MockTopicManager::admitting(vec![])), // refuses everything
12230        );
12231        let engine = Engine::new(
12232            managers,
12233            HashMap::new(),
12234            Box::new(MemoryStorage::new()),
12235            Some(Box::new(adv)),
12236            EngineConfig {
12237                hosting_url: Some("https://valid.example.com".to_string()),
12238                suppress_default_sync_advertisements: true,
12239                ..Default::default()
12240            },
12241        );
12242        let report = engine.sync_advertisements().await.unwrap();
12243        assert!(report.ok(), "no ERROR occurred: {report:?}");
12244        assert_eq!(report.to_create, 2, "{report:?}");
12245        assert_eq!(report.admitted_total(), 1, "{report:?}");
12246        assert!(
12247            !report.effective(),
12248            "a partial admit must not read as success: {report:?}"
12249        );
12250    }
12251
12252    #[tokio::test]
12253    async fn sync_report_lookup_failure_refuses_creation() {
12254        // #320 M2: a transient read failure must never become "current ads
12255        // = none" and re-create (re-pay) the whole set — creation is
12256        // REFUSED and the failure lands in the report.
12257        let (mut adv, created) = TrackingAdvertiser::new();
12258        adv.find_fails = true;
12259        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12260        assert!(!report.ok(), "{report:?}");
12261        assert!(!report.effective());
12262        assert!(
12263            report
12264                .lookup_error
12265                .as_deref()
12266                .unwrap_or_default()
12267                .contains("mock lookup refused"),
12268            "{report:?}"
12269        );
12270        assert_eq!(report.to_create, 0, "creation refused, not attempted");
12271        assert!(
12272            created.lock().unwrap().is_empty(),
12273            "no create call on a blind read"
12274        );
12275        assert!(report.submit_error.is_none());
12276    }
12277
12278    #[tokio::test]
12279    async fn sync_report_counts_skipped_revocations() {
12280        // #320 L2: `to_revoke: N` with a v1 no-op advertiser must be
12281        // distinguishable from a revoke that actually ran.
12282        let (mut adv, _) = TrackingAdvertiser::new();
12283        adv.revoke_empty = true;
12284        adv.existing_ship = vec![
12285            Advertisement {
12286                protocol: Protocol::Ship,
12287                identity_key: "02aa".to_string(),
12288                domain: "https://valid.example.com".to_string(),
12289                topic_or_service: "tm_test".to_string(),
12290                beef: None,
12291                output_index: None,
12292            },
12293            Advertisement {
12294                protocol: Protocol::Ship,
12295                identity_key: "02aa".to_string(),
12296                domain: "https://valid.example.com".to_string(),
12297                topic_or_service: "tm_stale".to_string(), // no longer configured
12298                beef: None,
12299                output_index: None,
12300            },
12301        ];
12302        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12303        assert!(report.ok(), "{report:?}");
12304        assert_eq!(report.to_revoke, 1, "tm_stale is stale");
12305        assert_eq!(
12306            report.revoke_skipped, 1,
12307            "the v1 no-op must be visible, not silent: {report:?}"
12308        );
12309        assert!(report.revoke_error.is_none());
12310        assert!(report.revoke_submit_error.is_none());
12311    }
12312
12313    // ── complete_missing_proofs (#130) ─────────────────────────────────
12314
12315    use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
12316    use std::cell::Cell;
12317
12318    /// A single raw mainnet tx (no proof) — used to build a proofless BEEF.
12319    const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12320
12321    /// Build a proofless single-tx BEEF + return `(beef_bytes, txid)`.
12322    fn proofless_beef() -> (Vec<u8>, String) {
12323        use bsv_rs::transaction::{Beef, Transaction};
12324        let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
12325        let txid = tx.id();
12326        let mut beef = Beef::new();
12327        beef.merge_transaction(tx);
12328        (beef.to_binary(), txid)
12329    }
12330
12331    // ── #284: stitching a proof must never rebuild the stored BEEF ─────────
12332
12333    /// The corruption shape observed live on rust-beta: a stored BEEF whose
12334    /// SUBJECT is not the last tx in wire order. The old rewrite went through
12335    /// `Transaction::from_beef(bytes, None)`, whose None arm picks
12336    /// `txs.last()` — the PARENT here — and `to_beef(true)` then serialized the
12337    /// parent's world, dropping the subject from its own row. TEST_BEEF_HEX is
12338    /// exactly a two-tx chain (parent + child spending it), so re-encoding it
12339    /// child-first reproduces the trigger byte-for-byte.
12340    fn subject_not_last_beef() -> (Vec<u8>, String, String) {
12341        use bsv_rs::transaction::Beef;
12342        let parsed = Beef::from_binary(&test_beef()).unwrap();
12343        let parent = parsed.txs[0].tx().unwrap().clone();
12344        let child = parsed.txs[1].tx().unwrap().clone();
12345        let (parent_id, child_id) = (parent.id(), child.id());
12346        assert_ne!(parent_id, child_id);
12347
12348        // Serialize via to_writer, NOT to_binary: to_binary re-sorts
12349        // parents-first (subject last), which is precisely why bsv_rs's own
12350        // output never triggers the bug. The corrupt rows held WALLET
12351        // serialized bytes stored verbatim at admit time, whose wire order is
12352        // whatever the wallet emitted — model that by writing the child first.
12353        let mut beef = Beef::new();
12354        beef.merge_transaction(child); // subject FIRST — the trigger
12355        beef.merge_transaction(parent);
12356        let mut w = bsv_rs::primitives::encoding::Writer::new();
12357        beef.to_writer(&mut w);
12358        let bytes = w.into_bytes();
12359
12360        // The premise the whole test rests on: wire order preserved, subject
12361        // genuinely not last.
12362        let reread = Beef::from_binary(&bytes).unwrap();
12363        assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
12364        (bytes, child_id, parent_id)
12365    }
12366
12367    #[test]
12368    fn stitch_preserves_the_subject_when_it_is_not_last() {
12369        use bsv_rs::transaction::{Beef, MerklePath};
12370        let (stored, child_id, parent_id) = subject_not_last_beef();
12371        let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
12372
12373        let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
12374            .expect("stitch must succeed on a beef that contains the txid");
12375
12376        let after = Beef::from_binary(&out).unwrap();
12377        assert!(
12378            after.find_txid(&child_id).is_some(),
12379            "the SUBJECT must survive its own proof-completion"
12380        );
12381        assert!(
12382            after.find_txid(&parent_id).is_some(),
12383            "the ancestry must survive too"
12384        );
12385        assert!(
12386            after.find_bump(&child_id).is_some(),
12387            "the stitched bump must prove the subject"
12388        );
12389        assert!(
12390            after.find_bump(&parent_id).is_none(),
12391            "the parent must NOT be claimed by the child's bump"
12392        );
12393    }
12394
12395    /// The old path, reproduced against the same input, to pin WHY the new one
12396    /// exists: it demonstrably drops the subject. If bsv_rs ever changes
12397    /// `from_beef(_, None)` to honor the subject, this starts failing and the
12398    /// comment trail can be revisited — until then it documents the hazard.
12399    #[test]
12400    fn the_old_round_trip_demonstrably_dropped_the_subject() {
12401        use bsv_rs::transaction::{Beef, Transaction};
12402        let (stored, child_id, _parent_id) = subject_not_last_beef();
12403        let tx = Transaction::from_beef(&stored, None).unwrap();
12404        assert_ne!(
12405            tx.id(),
12406            child_id,
12407            "from_beef(None) picks txs.last(), not the subject — the trigger"
12408        );
12409        let rebuilt = tx.to_beef(true).unwrap();
12410        let reparsed = Beef::from_binary(&rebuilt).unwrap();
12411        assert!(
12412            reparsed.find_txid(&child_id).is_none(),
12413            "the rebuild loses the subject — exactly the #284 artifact"
12414        );
12415    }
12416
12417    /// bsv-low M19 R2 round 3 (review HIGH-1): a stored BEEF whose SUBJECT
12418    /// sits on a stale (orphan) bump at height H, re-stitched with a
12419    /// same-height DIFFERENT-root proof, must serialize with the subject on
12420    /// the NEW bump and the orphan bump dropped — otherwise the hop leg
12421    /// churns and re-latches the orphan proof forever.
12422    #[test]
12423    fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
12424        use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
12425        // subject spends a real parent; both are raw in the BEEF
12426        let parent = Transaction::from_hex(RAW_PARENT).unwrap();
12427        let parent_id = parent.id();
12428        let subject = child_of(&parent_id, 0);
12429        let subject_id = subject.id();
12430        // the ORPHAN bump: a two-leaf block at H so its root is NOT the txid,
12431        // and it differs from the canonical block's root
12432        let orphan = MerklePath::new_unchecked(
12433            965_771,
12434            vec![vec![
12435                MerklePathLeaf::new_txid(0, subject_id.clone()),
12436                MerklePathLeaf::new(1, "aa".repeat(32)),
12437            ]],
12438        )
12439        .unwrap();
12440        let mut beef = Beef::new();
12441        let bi = beef.merge_bump(orphan.clone());
12442        beef.merge_raw_tx(parent.to_binary(), None);
12443        beef.merge_raw_tx(subject.to_binary(), Some(bi));
12444        let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
12445        let stored = beef.to_binary();
12446        assert_eq!(
12447            Beef::from_binary(&stored)
12448                .unwrap()
12449                .find_bump(&subject_id)
12450                .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
12451            Some(orphan_root.clone())
12452        );
12453        // the canonical proof at the SAME height, a DIFFERENT root
12454        let canonical = MerklePath::new_unchecked(
12455            965_771,
12456            vec![vec![
12457                MerklePathLeaf::new_txid(0, subject_id.clone()),
12458                MerklePathLeaf::new(1, "bb".repeat(32)),
12459            ]],
12460        )
12461        .unwrap();
12462        let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
12463        assert_ne!(
12464            canonical_root, orphan_root,
12465            "the reorg shape: same height, new root"
12466        );
12467        let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
12468        let after = Beef::from_binary(&out).unwrap();
12469        // the subject's OWN bump is the canonical one
12470        let own = after
12471            .find_txid(&subject_id)
12472            .and_then(BeefTx::bump_index)
12473            .and_then(|bi| after.bumps.get(bi))
12474            .unwrap();
12475        assert_eq!(
12476            own.compute_root(Some(&subject_id)).unwrap(),
12477            canonical_root,
12478            "the subject re-anchored to the new bump"
12479        );
12480        // the orphan bump is gone (only the canonical one remains)
12481        assert_eq!(
12482            after.bumps.len(),
12483            1,
12484            "the unreferenced orphan bump was dropped"
12485        );
12486        // and a reader that uses the tx's OWN bump sees canonical, never orphan
12487        assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
12488    }
12489
12490    #[test]
12491    fn stitch_refuses_a_beef_that_lacks_the_txid() {
12492        use bsv_rs::transaction::MerklePath;
12493        let (stored, _c, _p) = subject_not_last_beef();
12494        let foreign = "aa".repeat(32);
12495        let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
12496        assert!(
12497            Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
12498            "no txid in the beef -> no write, never garbage"
12499        );
12500    }
12501
12502    /// A real mainnet raw tx, the unproven parent for the re-anchor pin.
12503    const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12504
12505    /// A minimal raw tx spending `source:vout` with one OP_TRUE output.
12506    fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
12507        let mut sx = String::from("0100000001");
12508        let mut prev = hex::decode(source_txid).unwrap();
12509        prev.reverse();
12510        sx.push_str(&hex::encode(prev));
12511        sx.push_str(&hex::encode(vout.to_le_bytes()));
12512        sx.push_str("00ffffffff01");
12513        sx.push_str(&hex::encode(1000u64.to_le_bytes()));
12514        sx.push_str("015100000000");
12515        bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
12516    }
12517
12518    /// A minimal valid single-leaf BUMP hex proving `txid` at `height`.
12519    fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
12520        use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
12521        let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
12522        MerklePath::new_unchecked(height, vec![vec![leaf]])
12523            .unwrap()
12524            .to_hex()
12525    }
12526
12527    /// Mock fetcher: returns a configured proof (or none) for any txid, and
12528    /// counts how many times it was asked.
12529    struct StubFetcher {
12530        proof: Option<String>,
12531        calls: Cell<u32>,
12532    }
12533
12534    #[async_trait(?Send)]
12535    impl AncestorFetcher for StubFetcher {
12536        async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
12537            self.calls.set(self.calls.get() + 1);
12538            Ok(FetchedAncestor {
12539                raw_tx: PROOFLESS_RAW_TX.to_string(),
12540                proof: self.proof.clone(),
12541            })
12542        }
12543
12544        /// Models a fetcher whose header source CONFIRMS a stored bump — so the
12545        /// already-structurally-proven re-verify gate (the window-clog path)
12546        /// latches without a re-fetch, exactly as before the #192/#193 hardening.
12547        /// (`verified_proof_for` is left at its default: it delegates to
12548        /// `fetch_ancestor` above, so the `calls` counter still tracks fetches.)
12549        async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
12550            true
12551        }
12552    }
12553
12554    /// Build an engine over a storage holding one proofless output, with an
12555    /// optional ancestor fetcher.
12556    async fn engine_with_proofless_output(
12557        fetcher: Option<std::rc::Rc<StubFetcher>>,
12558    ) -> (Engine, String) {
12559        let (beef, txid) = proofless_beef();
12560        let storage = MemoryStorage::new();
12561        storage
12562            .insert_output(&Output {
12563                txid: txid.clone(),
12564                output_index: 0,
12565                output_script: vec![0x76],
12566                satoshis: 1_000_000_000,
12567                topic: "Hello".to_string(),
12568                spent: false,
12569                outputs_consumed: vec![],
12570                consumed_by: vec![],
12571                beef: Some(beef),
12572                block_height: None,
12573                score: Some(1.0),
12574            })
12575            .await
12576            .unwrap();
12577
12578        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12579        managers.insert(
12580            "Hello".into(),
12581            Box::new(MockTopicManager::admitting(vec![0])),
12582        );
12583        let mut engine = Engine::new(
12584            managers,
12585            HashMap::new(),
12586            Box::new(storage),
12587            None,
12588            EngineConfig::default(),
12589        );
12590        if let Some(f) = fetcher {
12591            engine.set_ancestor_fetcher(f);
12592        }
12593        (engine, txid)
12594    }
12595
12596    #[tokio::test]
12597    async fn proofs_completion_uses_dedicated_courier() {
12598        let (_, txid) = proofless_beef();
12599        let shared = std::rc::Rc::new(StubFetcher {
12600            proof: None,
12601            calls: Cell::new(0),
12602        });
12603        let dedicated = StubFetcher {
12604            proof: Some(single_leaf_bump_hex(&txid, 850_000)),
12605            calls: Cell::new(0),
12606        };
12607        let (engine, _) = engine_with_proofless_output(Some(shared.clone())).await;
12608        assert_eq!(
12609            engine
12610                .complete_missing_proofs(1, 0)
12611                .await
12612                .unwrap()
12613                .completed,
12614            0
12615        );
12616        assert_eq!(
12617            engine
12618                .complete_missing_proofs_with(&dedicated, 1, 0)
12619                .await
12620                .unwrap()
12621                .completed,
12622            1
12623        );
12624        assert_eq!(shared.calls.get(), 1);
12625        assert_eq!(dedicated.calls.get(), 1);
12626        let output = engine
12627            .storage()
12628            .find_output(&txid, 0, Some("Hello"), None, true)
12629            .await
12630            .unwrap()
12631            .unwrap();
12632        assert!(
12633            crate::beef_limits::own_proof(output.beef.as_ref().unwrap(), &txid)
12634                .unwrap()
12635                .is_some()
12636        );
12637    }
12638
12639    #[tokio::test]
12640    async fn complete_missing_proofs_noop_without_fetcher() {
12641        // No ancestor fetcher configured → pure no-op (production default).
12642        let (engine, _txid) = engine_with_proofless_output(None).await;
12643        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12644        assert_eq!(summary, ProofCompletionSummary::default());
12645    }
12646
12647    #[tokio::test]
12648    async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
12649        let (_beef, txid) = proofless_beef();
12650        let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12651        let fetcher = std::rc::Rc::new(StubFetcher {
12652            proof: Some(proof_hex),
12653            calls: Cell::new(0),
12654        });
12655        let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12656
12657        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12658        assert_eq!(summary.scanned, 1);
12659        assert_eq!(summary.proofless, 1);
12660        assert_eq!(
12661            summary.completed, 1,
12662            "the proofless BEEF should be completed"
12663        );
12664        assert_eq!(fetcher.calls.get(), 1);
12665
12666        // The stored BEEF now carries a proof for the target tx + the output
12667        // got its block height.
12668        let out = engine
12669            .storage()
12670            .find_output(&txid, 0, Some("Hello"), None, true)
12671            .await
12672            .unwrap()
12673            .unwrap();
12674        assert_eq!(out.block_height, Some(850_000));
12675        let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
12676        assert!(
12677            beef.find_txid(&txid)
12678                .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
12679            "completed BEEF must prove the target tx"
12680        );
12681
12682        // A second pass is a no-op: the tx is now proven, so it is not counted
12683        // proofless and the fetcher is not asked again.
12684        let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
12685        assert_eq!(summary2.proofless, 0);
12686        assert_eq!(summary2.completed, 0);
12687        assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
12688    }
12689
12690    #[tokio::test]
12691    async fn complete_missing_proofs_skips_unconfirmed() {
12692        // Fetcher reachable but the tx is unmined (no BUMP) → skipped, not
12693        // errored, and counted still_unconfirmed for retry.
12694        let fetcher = std::rc::Rc::new(StubFetcher {
12695            proof: None,
12696            calls: Cell::new(0),
12697        });
12698        let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12699        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12700        assert_eq!(summary.proofless, 1);
12701        assert_eq!(summary.completed, 0);
12702        assert_eq!(summary.still_unconfirmed, 1);
12703        assert_eq!(fetcher.calls.get(), 1);
12704    }
12705
12706    /// Build an engine like [`engine_with_proofless_output`] but keep a shared
12707    /// handle to the `MemoryStorage` (via the `Rc<T: Storage>` delegation) so
12708    /// the test can advance its deterministic clock — the push-primary
12709    /// backstop age-gate tests (#228) need to age rows without sleeping.
12710    async fn engine_with_proofless_output_shared(
12711        fetcher: Option<std::rc::Rc<StubFetcher>>,
12712    ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
12713        let (beef, txid) = proofless_beef();
12714        let storage = std::rc::Rc::new(MemoryStorage::new());
12715        storage
12716            .insert_output(&Output {
12717                txid: txid.clone(),
12718                output_index: 0,
12719                output_script: vec![0x76],
12720                satoshis: 1_000_000_000,
12721                topic: "Hello".to_string(),
12722                spent: false,
12723                outputs_consumed: vec![],
12724                consumed_by: vec![],
12725                beef: Some(beef),
12726                block_height: None,
12727                score: Some(1.0),
12728            })
12729            .await
12730            .unwrap();
12731
12732        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12733        managers.insert(
12734            "Hello".into(),
12735            Box::new(MockTopicManager::admitting(vec![0])),
12736        );
12737        let mut engine = Engine::new(
12738            managers,
12739            HashMap::new(),
12740            Box::new(storage.clone()),
12741            None,
12742            EngineConfig::default(),
12743        );
12744        if let Some(f) = fetcher {
12745            engine.set_ancestor_fetcher(f);
12746        }
12747        (engine, txid, storage)
12748    }
12749
12750    #[tokio::test]
12751    async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
12752        // Push-primary backstop (#228): a FRESHLY stored proofless tx must NOT
12753        // be polled — its proof is expected via /arc-ingest at push speed. The
12754        // gated pass scans nothing and never asks the fetcher.
12755        let proof_hex = {
12756            let (_beef, txid) = proofless_beef();
12757            single_leaf_bump_hex(&txid, 850_000)
12758        };
12759        let fetcher = std::rc::Rc::new(StubFetcher {
12760            proof: Some(proof_hex),
12761            calls: Cell::new(0),
12762        });
12763        let (engine, _txid, storage) =
12764            engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12765
12766        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12767        assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
12768        assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
12769
12770        // WEBHOOK-OUTAGE DEGRADATION: no push ever arrives. Once the row is
12771        // older than the gate, the SAME backstop pass polls and completes it
12772        // exactly as the pre-#228 behaviour — degradation is to polling,
12773        // never to nothing.
12774        storage.advance_clock(1800);
12775        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12776        assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
12777        assert_eq!(summary.completed, 1, "the backstop completes the proof");
12778        assert_eq!(fetcher.calls.get(), 1);
12779    }
12780
12781    #[tokio::test]
12782    async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
12783        // Pushed-proof-then-chaser-skips (#228): a proof arriving via the real
12784        // /arc-ingest producer path (`handle_new_merkle_proof` → the
12785        // `update_transaction_beef` stitch, which latches `has_proof`) drops
12786        // the tx out of the poll candidate set entirely — even after the age
12787        // gate opens, the chaser never asks the fetcher about it.
12788        let fetcher = std::rc::Rc::new(StubFetcher {
12789            proof: None,
12790            calls: Cell::new(0),
12791        });
12792        let (engine, txid, storage) =
12793            engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12794
12795        // The push lands (arc-ingest calls exactly this after its own
12796        // chaintracks verify).
12797        let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12798        engine
12799            .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
12800            .await
12801            .unwrap();
12802
12803        // Age the row well past the backstop gate: still skipped — the pushed
12804        // proof, not the age, is what removes it from the chaser's world.
12805        storage.advance_clock(1_000_000);
12806        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12807        assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
12808        assert_eq!(summary.completed, 0);
12809        assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
12810    }
12811
12812    #[tokio::test]
12813    async fn complete_missing_proofs_respects_limit() {
12814        // Two proofless txs, limit 1 → only one row is scanned this tick.
12815        let (beef1, txid1) = proofless_beef();
12816        // A second distinct proofless tx (different raw tx → different txid).
12817        let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
12818        let (beef2, txid2) = {
12819            use bsv_rs::transaction::{Beef, Transaction};
12820            let tx = Transaction::from_hex(raw2).unwrap();
12821            let id = tx.id();
12822            let mut b = Beef::new();
12823            b.merge_transaction(tx);
12824            (b.to_binary(), id)
12825        };
12826
12827        let storage = MemoryStorage::new();
12828        for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
12829            storage
12830                .insert_output(&Output {
12831                    txid: txid.clone(),
12832                    output_index: 0,
12833                    output_script: vec![0x76],
12834                    satoshis: 1000,
12835                    topic: "Hello".to_string(),
12836                    spent: false,
12837                    outputs_consumed: vec![],
12838                    consumed_by: vec![],
12839                    beef: Some(beef),
12840                    block_height: None,
12841                    score: Some(1.0),
12842                })
12843                .await
12844                .unwrap();
12845        }
12846
12847        let fetcher = std::rc::Rc::new(StubFetcher {
12848            proof: None,
12849            calls: Cell::new(0),
12850        });
12851        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12852        managers.insert(
12853            "Hello".into(),
12854            Box::new(MockTopicManager::admitting(vec![0])),
12855        );
12856        let mut engine = Engine::new(
12857            managers,
12858            HashMap::new(),
12859            Box::new(storage),
12860            None,
12861            EngineConfig::default(),
12862        );
12863        engine.set_ancestor_fetcher(fetcher.clone());
12864
12865        let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
12866        assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
12867    }
12868
12869    #[tokio::test]
12870    async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
12871        // Window-clog regression (#130): migration 0010 defaulted EVERY
12872        // existing `transactions` row to has_proof = 0, including rows whose
12873        // stored BEEF already carries a proof (e.g. GASP-synced txs). The
12874        // candidate query (`WHERE has_proof = 0 LIMIT n`) returns those rows
12875        // every tick; the engine skips them but — before this fix — never
12876        // flipped the flag, so they lingered in the bounded window forever and
12877        // starved genuinely-proofless rows behind them. The fix marks each
12878        // already-proven scanned row so it drops out of the NEXT pass.
12879        use bsv_rs::transaction::{Beef, MerklePath, Transaction};
12880
12881        let storage = MemoryStorage::new();
12882
12883        // Seed several ALREADY-PROVEN rows. They are inserted via
12884        // `insert_output`, which (modeling migration 0010's default) does NOT
12885        // set the proof flag — so despite carrying a valid proof they show up
12886        // as has_proof = 0 candidates: the exact clog.
12887        let proven_raws = [
12888            "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
12889            "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
12890        ];
12891        let mut proven_ids = Vec::new();
12892        for raw in proven_raws {
12893            let mut tx = Transaction::from_hex(raw).unwrap();
12894            let id = tx.id();
12895            tx.merkle_path =
12896                Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
12897            // to_beef(true) carries the merkle proof into the BEEF, so the row
12898            // genuinely IS proven — yet its flag stays 0 (insert path).
12899            let proven_beef = tx.to_beef(true).unwrap();
12900            assert_eq!(
12901                Beef::from_binary(&proven_beef)
12902                    .unwrap()
12903                    .find_txid(&id)
12904                    .map(bsv_rs::transaction::BeefTx::has_proof),
12905                Some(true),
12906                "proven fixture row must carry its proof"
12907            );
12908            proven_ids.push(id.clone());
12909            storage
12910                .insert_output(&Output {
12911                    txid: id,
12912                    output_index: 0,
12913                    output_script: vec![0x76],
12914                    satoshis: 1000,
12915                    topic: "Hello".to_string(),
12916                    spent: false,
12917                    outputs_consumed: vec![],
12918                    consumed_by: vec![],
12919                    beef: Some(proven_beef),
12920                    block_height: Some(800_000),
12921                    score: Some(1.0),
12922                })
12923                .await
12924                .unwrap();
12925        }
12926
12927        let fetcher = std::rc::Rc::new(StubFetcher {
12928            proof: None,
12929            calls: Cell::new(0),
12930        });
12931        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12932        managers.insert(
12933            "Hello".into(),
12934            Box::new(MockTopicManager::admitting(vec![0])),
12935        );
12936        let mut engine = Engine::new(
12937            managers,
12938            HashMap::new(),
12939            Box::new(storage),
12940            None,
12941            EngineConfig::default(),
12942        );
12943        engine.set_ancestor_fetcher(fetcher.clone());
12944
12945        // First pass over a window covering both proven rows: each is detected
12946        // already-proven and marked. Nothing proofless, nothing fetched.
12947        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12948        assert_eq!(summary.scanned, proven_ids.len());
12949        assert_eq!(summary.proofless, 0);
12950        assert_eq!(summary.completed, 0);
12951        assert_eq!(
12952            summary.already_proven,
12953            proven_ids.len(),
12954            "every already-proven row is marked this pass"
12955        );
12956        assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
12957
12958        // Window has advanced: the NEXT candidate query no longer returns the
12959        // marked rows, so the pass scans nothing.
12960        let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
12961        assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
12962        assert_eq!(summary2.already_proven, 0);
12963    }
12964
12965    #[tokio::test]
12966    async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
12967        // The window-clog in action with a small limit: a genuinely-proofless
12968        // backlog row sits behind already-proven (stale-flag) rows. Each tick
12969        // marks the proven rows it scans (dropping them out), so over a few
12970        // bounded ticks the window advances until the proofless tx is reached
12971        // + completed — instead of being starved forever.
12972        use bsv_rs::transaction::{MerklePath, Transaction};
12973
12974        let storage = MemoryStorage::new();
12975
12976        let proven_raws = [
12977            "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
12978        ];
12979        for raw in proven_raws {
12980            let mut tx = Transaction::from_hex(raw).unwrap();
12981            let id = tx.id();
12982            tx.merkle_path =
12983                Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
12984            let proven_beef = tx.to_beef(true).unwrap();
12985            storage
12986                .insert_output(&Output {
12987                    txid: id,
12988                    output_index: 0,
12989                    output_script: vec![0x76],
12990                    satoshis: 1000,
12991                    topic: "Hello".to_string(),
12992                    spent: false,
12993                    outputs_consumed: vec![],
12994                    consumed_by: vec![],
12995                    beef: Some(proven_beef),
12996                    block_height: Some(800_000),
12997                    score: Some(1.0),
12998                })
12999                .await
13000                .unwrap();
13001        }
13002
13003        // The historical proofless tx (the f584f846-style backlog row).
13004        let (proofless, proofless_txid) = proofless_beef();
13005        storage
13006            .insert_output(&Output {
13007                txid: proofless_txid.clone(),
13008                output_index: 0,
13009                output_script: vec![0x76],
13010                satoshis: 1000,
13011                topic: "Hello".to_string(),
13012                spent: false,
13013                outputs_consumed: vec![],
13014                consumed_by: vec![],
13015                beef: Some(proofless),
13016                block_height: None,
13017                score: Some(1.0),
13018            })
13019            .await
13020            .unwrap();
13021
13022        let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
13023        let fetcher = std::rc::Rc::new(StubFetcher {
13024            proof: Some(proof_hex),
13025            calls: Cell::new(0),
13026        });
13027        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
13028        managers.insert(
13029            "Hello".into(),
13030            Box::new(MockTopicManager::admitting(vec![0])),
13031        );
13032        let mut engine = Engine::new(
13033            managers,
13034            HashMap::new(),
13035            Box::new(storage),
13036            None,
13037            EngineConfig::default(),
13038        );
13039        engine.set_ancestor_fetcher(fetcher.clone());
13040
13041        // Run bounded ticks (limit 1) until the proofless tx is completed. With
13042        // 2 rows total a couple of ticks suffice; cap iterations defensively.
13043        let mut completed_total = 0;
13044        for _ in 0..5 {
13045            let s = engine.complete_missing_proofs(1, 0).await.unwrap();
13046            completed_total += s.completed;
13047            if completed_total > 0 {
13048                break;
13049            }
13050        }
13051        assert_eq!(
13052            completed_total, 1,
13053            "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
13054        );
13055
13056        let out = engine
13057            .storage()
13058            .find_output(&proofless_txid, 0, Some("Hello"), None, true)
13059            .await
13060            .unwrap()
13061            .unwrap();
13062        let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
13063        assert_eq!(
13064            beef.find_txid(&proofless_txid)
13065                .map(bsv_rs::transaction::BeefTx::has_proof),
13066            Some(true),
13067            "the old proofless tx now carries its proof"
13068        );
13069    }
13070
13071    // ── S2 queue-durable admission (bsv-low 2026-08-29): Phase-3 faults ──
13072    //
13073    // The phantom class: under a D1 storm `submit` returned Ok(steak) while
13074    // its writes failed — the ack outlived the admission. These pins hold
13075    // the new contract: a fault is REPORTED, the faulted topic is NOT
13076    // recorded as applied, and a replay of the same bytes re-applies.
13077
13078    use crate::storage::StorageError;
13079    use std::rc::Rc;
13080
13081    /// `MemoryStorage` behind two fault switches. Every method delegates;
13082    /// the two the tests flip model a D1 write fault (`insert_output`) and
13083    /// a D1 read fault (`find_output` — the validation's previous-coin scan
13084    /// AND Phase 3's stale/consumed lookups).
13085    struct FaultingStorage {
13086        inner: MemoryStorage,
13087        fail_insert_output: Cell<bool>,
13088        fail_find_output: Cell<bool>,
13089    }
13090
13091    impl FaultingStorage {
13092        fn new() -> Rc<Self> {
13093            Rc::new(Self {
13094                inner: MemoryStorage::new(),
13095                fail_insert_output: Cell::new(false),
13096                fail_find_output: Cell::new(false),
13097            })
13098        }
13099    }
13100
13101    #[async_trait(?Send)]
13102    impl Storage for FaultingStorage {
13103        async fn insert_output_with_origin(
13104            &self,
13105            output: &Output,
13106            gasp: bool,
13107        ) -> Result<(), StorageError> {
13108            if self.fail_insert_output.get() {
13109                return Err(StorageError::Database(
13110                    "D1_ERROR: storage overloaded".into(),
13111                ));
13112            }
13113            self.inner.insert_output_with_origin(output, gasp).await
13114        }
13115        async fn read_fault_memos(
13116            &self,
13117            host: &str,
13118            topic: &str,
13119            outpoints: Option<&[String]>,
13120        ) -> Result<crate::fault_memo::FaultMemoBatch, StorageError> {
13121            self.inner.read_fault_memos(host, topic, outpoints).await
13122        }
13123
13124        async fn put_deferred_graph(
13125            &self,
13126            record: &crate::gasp::DeferredGraph,
13127        ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
13128            self.inner.put_deferred_graph(record).await
13129        }
13130        async fn reserve_unanchorable_retry(
13131            &self,
13132            record: &crate::gasp::DeferredGraph,
13133        ) -> Result<bool, StorageError> {
13134            self.inner.reserve_unanchorable_retry(record).await
13135        }
13136        async fn find_deferred_graphs(
13137            &self,
13138            host: &str,
13139            topic: &str,
13140        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
13141            self.inner.find_deferred_graphs(host, topic).await
13142        }
13143        async fn get_deferred_graph(
13144            &self,
13145            host: &str,
13146            topic: &str,
13147            outpoint: &str,
13148        ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
13149            self.inner.get_deferred_graph(host, topic, outpoint).await
13150        }
13151        async fn delete_deferred_graph(
13152            &self,
13153            host: &str,
13154            topic: &str,
13155            outpoint: &str,
13156        ) -> Result<(), StorageError> {
13157            self.inner
13158                .delete_deferred_graph(host, topic, outpoint)
13159                .await
13160        }
13161        async fn record_peer_sync_outcome(
13162            &self,
13163            host: &str,
13164            topic: &str,
13165            success: bool,
13166        ) -> Result<(), StorageError> {
13167            self.inner
13168                .record_peer_sync_outcome(host, topic, success)
13169                .await
13170        }
13171        async fn get_peer_sync_health(
13172            &self,
13173            host: &str,
13174            topic: &str,
13175        ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
13176            self.inner.get_peer_sync_health(host, topic).await
13177        }
13178        async fn record_peer_sync_yield(
13179            &self,
13180            host: &str,
13181            topic: &str,
13182            yielded: bool,
13183        ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
13184            self.inner
13185                .record_peer_sync_yield(host, topic, yielded)
13186                .await
13187        }
13188        async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
13189            self.insert_output_with_origin(output, false).await
13190        }
13191        async fn delete_output(
13192            &self,
13193            txid: &str,
13194            output_index: u32,
13195            topic: &str,
13196        ) -> Result<(), StorageError> {
13197            self.inner.delete_output(txid, output_index, topic).await
13198        }
13199        async fn mark_utxo_as_spent(
13200            &self,
13201            txid: &str,
13202            output_index: u32,
13203            topic: &str,
13204        ) -> Result<(), StorageError> {
13205            self.inner
13206                .mark_utxo_as_spent(txid, output_index, topic)
13207                .await
13208        }
13209        async fn update_consumed_by(
13210            &self,
13211            txid: &str,
13212            output_index: u32,
13213            topic: &str,
13214            consumed_by: &[Outpoint],
13215        ) -> Result<(), StorageError> {
13216            self.inner
13217                .update_consumed_by(txid, output_index, topic, consumed_by)
13218                .await
13219        }
13220        async fn update_transaction_beef(
13221            &self,
13222            txid: &str,
13223            beef: &[u8],
13224        ) -> Result<(), StorageError> {
13225            self.inner.update_transaction_beef(txid, beef).await
13226        }
13227        async fn insert_applied_transaction(
13228            &self,
13229            tx: &AppliedTransaction,
13230        ) -> Result<(), StorageError> {
13231            self.inner.insert_applied_transaction(tx).await
13232        }
13233        async fn does_applied_transaction_exist(
13234            &self,
13235            tx: &AppliedTransaction,
13236        ) -> Result<bool, StorageError> {
13237            self.inner.does_applied_transaction_exist(tx).await
13238        }
13239        async fn delete_applied_transaction(
13240            &self,
13241            tx: &AppliedTransaction,
13242        ) -> Result<(), StorageError> {
13243            self.inner.delete_applied_transaction(tx).await
13244        }
13245        async fn find_output(
13246            &self,
13247            txid: &str,
13248            output_index: u32,
13249            topic: Option<&str>,
13250            spent: Option<bool>,
13251            include_beef: bool,
13252        ) -> Result<Option<Output>, StorageError> {
13253            if self.fail_find_output.get() {
13254                return Err(StorageError::Database("D1_ERROR: read timed out".into()));
13255            }
13256            self.inner
13257                .find_output(txid, output_index, topic, spent, include_beef)
13258                .await
13259        }
13260        async fn find_outputs_for_transaction(
13261            &self,
13262            txid: &str,
13263            include_beef: bool,
13264        ) -> Result<Vec<Output>, StorageError> {
13265            self.inner
13266                .find_outputs_for_transaction(txid, include_beef)
13267                .await
13268        }
13269        async fn find_utxos_for_topic(
13270            &self,
13271            topic: &str,
13272            since: Option<f64>,
13273            limit: Option<u64>,
13274            include_beef: bool,
13275        ) -> Result<Vec<Output>, StorageError> {
13276            self.inner
13277                .find_utxos_for_topic(topic, since, limit, include_beef)
13278                .await
13279        }
13280        async fn update_last_interaction(
13281            &self,
13282            host: &str,
13283            topic: &str,
13284            since: u64,
13285        ) -> Result<(), StorageError> {
13286            self.inner.update_last_interaction(host, topic, since).await
13287        }
13288        async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
13289            self.inner.get_last_interaction(host, topic).await
13290        }
13291    }
13292
13293    fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
13294        let storage = FaultingStorage::new();
13295        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13296        managers.insert(
13297            "tm_test".to_string(),
13298            Box::new(MockTopicManager::admitting(admit_indices)),
13299        );
13300        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13301        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
13302        let engine = Engine::new(
13303            managers,
13304            lookup_services,
13305            Box::new(Rc::clone(&storage)),
13306            None,
13307            EngineConfig::default(),
13308        );
13309        (engine, storage)
13310    }
13311
13312    async fn applied(storage: &FaultingStorage) -> bool {
13313        storage
13314            .does_applied_transaction_exist(&AppliedTransaction {
13315                txid: TEST_TXID.to_string(),
13316                topic: "tm_test".to_string(),
13317            })
13318            .await
13319            .unwrap()
13320    }
13321
13322    /// The old behaviour, pinned: a clean submit is durable, records the
13323    /// topic as applied, and the output is in storage.
13324    #[tokio::test]
13325    async fn clean_submit_is_durable_and_records_applied() {
13326        let (engine, storage) = make_faulting_engine(vec![0]);
13327        let (steak, report) = engine
13328            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13329            .await
13330            .unwrap();
13331        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
13332        assert!(report.is_durable(), "{}", report.summary());
13333        assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
13334        assert!(applied(&storage).await);
13335        assert!(storage
13336            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13337            .await
13338            .unwrap()
13339            .is_some());
13340    }
13341
13342    /// THE PHANTOM, red→green: the insert fails, the steak still says
13343    /// "admitted" (the decision stands), but the report says NOT DURABLE and
13344    /// names the site — and the topic is NOT recorded as applied, so nothing
13345    /// downstream can treat the loss as final.
13346    #[tokio::test]
13347    async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
13348        let (engine, storage) = make_faulting_engine(vec![0]);
13349        storage.fail_insert_output.set(true);
13350        let (steak, report) = engine
13351            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13352            .await
13353            .unwrap();
13354        assert_eq!(
13355            steak["tm_test"].outputs_to_admit,
13356            vec![0],
13357            "the admission DECISION is unchanged by a write fault"
13358        );
13359        assert!(!report.is_durable());
13360        assert_eq!(report.faults.len(), 1, "{}", report.summary());
13361        assert_eq!(report.faults[0].topic, "tm_test");
13362        assert_eq!(report.faults[0].site, "insert_output");
13363        assert!(report.faults[0].error.contains("D1_ERROR"));
13364        assert!(report.applied_topics.is_empty());
13365        assert!(
13366            !applied(&storage).await,
13367            "a faulted topic must not be recorded as applied — the replay would be a dupe"
13368        );
13369        assert!(storage
13370            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13371            .await
13372            .unwrap()
13373            .is_none());
13374    }
13375
13376    /// The replay: same bytes, storage healthy again → re-validated (not a
13377    /// dupe), re-written, recorded. A THIRD submit is then the dupe it
13378    /// should be: nothing admitted, nothing to write, durable.
13379    #[tokio::test]
13380    async fn replay_after_a_fault_reapplies_and_records_applied() {
13381        let (engine, storage) = make_faulting_engine(vec![0]);
13382        storage.fail_insert_output.set(true);
13383        let (_, first) = engine
13384            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13385            .await
13386            .unwrap();
13387        assert!(!first.is_durable());
13388
13389        storage.fail_insert_output.set(false);
13390        let (steak, replay) = engine
13391            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13392            .await
13393            .unwrap();
13394        assert_eq!(
13395            steak["tm_test"].outputs_to_admit,
13396            vec![0],
13397            "re-validated, not deduped"
13398        );
13399        assert!(replay.is_durable(), "{}", replay.summary());
13400        assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
13401        assert!(applied(&storage).await);
13402        assert!(storage
13403            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13404            .await
13405            .unwrap()
13406            .is_some());
13407
13408        let (steak, third) = engine
13409            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13410            .await
13411            .unwrap();
13412        assert!(
13413            steak["tm_test"].outputs_to_admit.is_empty(),
13414            "now a genuine dupe: nothing admitted"
13415        );
13416        assert!(third.is_durable() && third.applied_topics.is_empty());
13417    }
13418
13419    /// The SPEND half of the phantom: a `find_output` fault during
13420    /// validation made a settle look like it consumed nothing, so the spend
13421    /// pointer was never written and the ack stood. Now the read fault is
13422    /// reported, the topic stays unrecorded, the previously-admitted coin
13423    /// is untouched — and the healthy replay consumes it.
13424    #[tokio::test]
13425    async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
13426        let (engine, storage) = make_faulting_engine(vec![0]);
13427        // The subject's own input: seed the coin it spends as a previously
13428        // admitted tm_test output.
13429        let tx = Transaction::from_beef(&test_beef(), None).unwrap();
13430        let source_txid = tx.inputs[0].get_source_txid().unwrap();
13431        let source_vout = tx.inputs[0].source_output_index;
13432        storage
13433            .insert_output(&Output {
13434                txid: source_txid.clone(),
13435                output_index: source_vout,
13436                output_script: vec![0x51],
13437                satoshis: 1,
13438                topic: "tm_test".to_string(),
13439                spent: false,
13440                outputs_consumed: vec![],
13441                consumed_by: vec![],
13442                beef: None,
13443                block_height: None,
13444                score: Some(1.0),
13445            })
13446            .await
13447            .unwrap();
13448
13449        storage.fail_find_output.set(true);
13450        let (_, faulted) = engine
13451            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13452            .await
13453            .unwrap();
13454        assert!(!faulted.is_durable());
13455        assert!(
13456            faulted.faults.iter().any(|f| f.site == "find_output"),
13457            "{}",
13458            faulted.summary()
13459        );
13460        assert!(
13461            !applied(&storage).await,
13462            "unrecorded: the replay must re-read"
13463        );
13464        storage.fail_find_output.set(false);
13465        assert!(
13466            storage
13467                .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13468                .await
13469                .unwrap()
13470                .is_some(),
13471            "the coin the faulted pass could not see is still there, untouched"
13472        );
13473
13474        let (_, replay) = engine
13475            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13476            .await
13477            .unwrap();
13478        assert!(replay.is_durable(), "{}", replay.summary());
13479        assert!(applied(&storage).await);
13480        // The mock manager retains no coins, so the consumed coin is stale
13481        // and deleted — the observable proof the spend was PROCESSED.
13482        assert!(
13483            storage
13484                .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13485                .await
13486                .unwrap()
13487                .is_none(),
13488            "the healthy replay consumed the coin the faulted pass missed"
13489        );
13490    }
13491
13492    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): a lookup service that missed
13493    /// its admit notification is told AGAIN from the engine's own stored
13494    /// outputs — one call per admitted output per service, the WholeTx body
13495    /// naming the subject — while `/submit` of the same bytes is deduped and
13496    /// tells nobody (the premise, RED-verified in the same test); a topic the
13497    /// engine never admitted this txid on yields 0 (the caller re-submits).
13498    #[tokio::test]
13499    async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
13500        use std::cell::RefCell;
13501        use std::rc::Rc;
13502
13503        struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
13504        #[async_trait(?Send)]
13505        impl LookupServiceTrait for CountingLs {
13506            fn admission_mode(&self) -> AdmissionMode {
13507                self.1
13508            }
13509            fn spend_notification_mode(&self) -> SpendNotificationMode {
13510                SpendNotificationMode::None
13511            }
13512            async fn output_admitted_by_topic(
13513                &self,
13514                payload: &OutputAdmittedByTopic,
13515            ) -> Result<(), LookupServiceError> {
13516                let rec = match payload {
13517                    OutputAdmittedByTopic::LockingScript {
13518                        txid,
13519                        output_index,
13520                        topic,
13521                        ..
13522                    } => (txid.clone(), *output_index, topic.clone()),
13523                    OutputAdmittedByTopic::WholeTx {
13524                        atomic_beef,
13525                        output_index,
13526                        topic,
13527                        ..
13528                    } => {
13529                        let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
13530                        (
13531                            beef.atomic_txid
13532                                .clone()
13533                                .expect("the WholeTx body NAMES its subject"),
13534                            *output_index,
13535                            topic.clone(),
13536                        )
13537                    }
13538                };
13539                self.0.borrow_mut().push(rec);
13540                Ok(())
13541            }
13542            async fn output_evicted(
13543                &self,
13544                _txid: &str,
13545                _output_index: u32,
13546            ) -> Result<(), LookupServiceError> {
13547                Ok(())
13548            }
13549            async fn lookup(
13550                &self,
13551                _question: &LookupQuestion,
13552            ) -> Result<LookupResult, LookupServiceError> {
13553                Ok(LookupResult::OutputList(Vec::new()))
13554            }
13555            async fn get_documentation(&self) -> String {
13556                "counting".to_string()
13557            }
13558            async fn get_metadata(&self) -> ServiceMetadata {
13559                ServiceMetadata {
13560                    name: "counting-ls".to_string(),
13561                    ..Default::default()
13562                }
13563            }
13564        }
13565
13566        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13567        managers.insert(
13568            "tm_test".to_string(),
13569            Box::new(MockTopicManager::admitting(vec![0])),
13570        );
13571        let seen = Rc::new(RefCell::new(Vec::new()));
13572        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13573        lookup_services.insert(
13574            "ls_whole".to_string(),
13575            Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
13576        );
13577        lookup_services.insert(
13578            "ls_script".to_string(),
13579            Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
13580        );
13581        let engine = Engine::new(
13582            managers,
13583            lookup_services,
13584            Box::new(MemoryStorage::new()),
13585            None,
13586            EngineConfig::default(),
13587        );
13588
13589        let tagged = test_tagged_beef(vec!["tm_test"]);
13590        engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13591        assert_eq!(seen.borrow().len(), 2, "one admit per service");
13592        let (txid, vout, _) = seen.borrow()[0].clone();
13593        assert_eq!(txid, TEST_TXID);
13594
13595        // the premise, RED-verified: the same bytes again are DEDUPED — /submit can never re-notify
13596        let (_, report) = engine
13597            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13598            .await
13599            .unwrap();
13600        assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
13601        assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
13602
13603        // the verb: told again from the stored outputs, the WholeTx body naming the subject
13604        let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
13605        assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
13606        assert_eq!(r.vouts, vec![vout]);
13607        assert_eq!(seen.borrow().len(), 4);
13608        assert!(seen.borrow()[2..]
13609            .iter()
13610            .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
13611
13612        // a topic the engine never admitted this txid on: nothing to re-notify (re-submit instead)
13613        let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
13614        assert_eq!((r0.outputs, r0.notified), (0, 0));
13615        assert_eq!(seen.borrow().len(), 4);
13616
13617        // idempotent: a second re-notify tells everyone again, faults nothing
13618        let r2 = engine
13619            .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
13620            .await
13621            .unwrap();
13622        assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
13623        assert_eq!(seen.borrow().len(), 6);
13624    }
13625
13626    /// bsv-low PLAN-PRE-LOOP4 §H4: a PHANTOM applied row (the topic admitted
13627    /// NOTHING) dedups every re-submit of those bytes; forgetting it lets the
13628    /// same bytes be judged again — while a REAL admission's row is never
13629    /// forgotten and a missing row is nothing to forget.
13630    #[tokio::test]
13631    async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
13632        // phantom: a TM that admits nothing still records the topic as applied
13633        let engine = make_engine(vec![]);
13634        let tagged = test_tagged_beef(vec!["tm_test"]);
13635        let (steak, r1) = engine
13636            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13637            .await
13638            .unwrap();
13639        assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
13640        assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
13641        let (_, r2) = engine
13642            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13643            .await
13644            .unwrap();
13645        assert_eq!(
13646            r2.deduped_topics,
13647            vec!["tm_test".to_string()],
13648            "the phantom row dedups the re-submit"
13649        );
13650        assert!(engine
13651            .forget_phantom_applied(TEST_TXID, "tm_test")
13652            .await
13653            .unwrap());
13654        let (_, r3) = engine
13655            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13656            .await
13657            .unwrap();
13658        assert!(
13659            r3.deduped_topics.is_empty(),
13660            "judged again after the forget"
13661        );
13662        assert!(
13663            !engine
13664                .forget_phantom_applied(TEST_TXID, "tm_other")
13665                .await
13666                .unwrap(),
13667            "no row: nothing to forget"
13668        );
13669
13670        // real: a TM that admits output 0 — its row is a real admission, never forgotten
13671        let real = make_engine(vec![0]);
13672        real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13673        assert!(!real
13674            .forget_phantom_applied(TEST_TXID, "tm_test")
13675            .await
13676            .unwrap());
13677        let (_, r4) = real
13678            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13679            .await
13680            .unwrap();
13681        assert_eq!(
13682            r4.deduped_topics,
13683            vec!["tm_test".to_string()],
13684            "a real admission still dedups"
13685        );
13686    }
13687
13688    // ── NL-6e: the SPV walk is linear in a BUMP's leaves (NL-8 W1) ─────
13689
13690    /// Named and generous (a debug build on a loaded machine): the walk at
13691    /// bsv-rs 0.4.0 took 30 to 36 s at 4,096 leaves in release and gave no
13692    /// answer in 120 s at 8,192 (NL-8 W1).
13693    const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
13694
13695    /// The wide BUMP's height (NL-8's `wide k` row).
13696    const WIDE_HEIGHT: u32 = 800_001;
13697
13698    /// `2^k` txids, display hex: the double SHA-256 of `i` (NL-8's probe).
13699    fn wide_leaves(k: u32) -> Vec<String> {
13700        (0..1u32 << k)
13701            .map(|i| {
13702                let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
13703                id.reverse();
13704                hex::encode(id)
13705            })
13706            .collect()
13707    }
13708
13709    /// One BUMP at [`WIDE_HEIGHT`] whose level 0 carries `txids` as hashed
13710    /// leaves and whose `k - 1` upper levels are empty: every node above
13711    /// level 0 is computed from it. Its root, by one walk.
13712    fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
13713        let k = txids.len().trailing_zeros() as usize;
13714        let mut levels = vec![Vec::new(); k];
13715        levels[0] = txids
13716            .iter()
13717            .enumerate()
13718            .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
13719            .collect();
13720        let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
13721        let root = bump.compute_root(None).unwrap();
13722        (bump, root)
13723    }
13724
13725    /// NL-8 W1's bytes: a V1 BEEF of the one wide BUMP and no transaction
13726    /// (`wide_14` at `2^k` leaves, byte for byte the runner's probe).
13727    fn w1_bytes(k: u32) -> (Vec<u8>, String) {
13728        let (bump, root) = wide_bump(&wide_leaves(k));
13729        let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
13730        beef.extend_from_slice(&bump.to_binary());
13731        beef.push(0x00);
13732        (beef, root)
13733    }
13734
13735    /// The entry, [`verify_spv_like_the_reference`], on its own thread under
13736    /// a tracker that carries `root` at [`WIDE_HEIGHT`]: its answer and its
13737    /// time, or a panic naming the bound.
13738    fn walk_within_bound(
13739        bytes: Vec<u8>,
13740        root: String,
13741        verify_scripts: bool,
13742        subject: String,
13743        what: &str,
13744    ) -> (Result<(), String>, std::time::Duration) {
13745        let (tx, rx) = std::sync::mpsc::channel();
13746        std::thread::spawn(move || {
13747            let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
13748            tracker.add_root(WIDE_HEIGHT, root);
13749            let runtime = tokio::runtime::Builder::new_current_thread()
13750                .build()
13751                .unwrap();
13752            let started = std::time::Instant::now();
13753            let verdict = runtime.block_on(verify_spv_like_the_reference(
13754                Some(&tracker),
13755                verify_scripts,
13756                &bytes,
13757                &subject,
13758                DoorBudget::DEFAULT,
13759            ));
13760            let verdict = match verdict {
13761                Ok(None) => Ok(()),
13762                Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
13763                Err(e) => Err(e.to_string()),
13764            };
13765            let _ = tx.send((verdict, started.elapsed()));
13766        });
13767        rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
13768            panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
13769        })
13770    }
13771
13772    /// The escape hatch's structural walk (`verify_scripts` false) on NL-8
13773    /// W1's bytes, 4,096 and 8,192 leaves: accepted, the one root asked of
13774    /// the tracker, well inside the bound.
13775    #[test]
13776    fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
13777        for k in [12u32, 13] {
13778            let (bytes, root) = w1_bytes(k);
13779            let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
13780            let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
13781            println!(
13782                "NL-6e W1 {what}: {verdict:?} in {:.3} s",
13783                took.as_secs_f64()
13784            );
13785            assert_eq!(verdict, Ok(()), "{what}");
13786        }
13787    }
13788
13789    /// The default walk (scripts run, `verify_scripts` true) over `2^k`
13790    /// proven parents in the one wide BUMP, each spent by a binary tree of
13791    /// two-input transactions whose root is the subject: every parent's
13792    /// root is the BUMP's, asked of the tracker, the inputs `2^(k+1) - 2`.
13793    fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
13794        use bsv_rs::script::{LockingScript, UnlockingScript};
13795        use bsv_rs::transaction::{TransactionInput, TransactionOutput};
13796        let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
13797        let spending = |txid: &str| {
13798            let mut input = TransactionInput::new(txid.to_string(), 0);
13799            input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
13800            input
13801        };
13802        let parents: Vec<Transaction> = (0..1u32 << k)
13803            .map(|i| {
13804                let mut tx = Transaction::new();
13805                // A coinbase's null outpoint, a distinct index each.
13806                let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
13807                input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
13808                tx.inputs.push(input);
13809                tx.outputs.push(TransactionOutput::new(1, op_true()));
13810                tx
13811            })
13812            .collect();
13813        let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
13814        let (bump, root) = wide_bump(&ids);
13815        let mut beef = Beef::new();
13816        let at = beef.merge_bump(bump);
13817        for parent in &parents {
13818            beef.merge_raw_tx(parent.to_binary(), Some(at));
13819        }
13820        let mut layer = ids;
13821        while layer.len() > 1 {
13822            layer = layer
13823                .chunks(2)
13824                .map(|pair| {
13825                    let mut tx = Transaction::new();
13826                    tx.inputs.push(spending(&pair[0]));
13827                    tx.inputs.push(spending(&pair[1]));
13828                    tx.outputs.push(TransactionOutput::new(0, op_true()));
13829                    beef.merge_raw_tx(tx.to_binary(), None);
13830                    tx.id()
13831                })
13832                .collect();
13833        }
13834        (beef.to_binary(), root, layer.remove(0))
13835    }
13836
13837    #[test]
13838    fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
13839        for k in [12u32, 13] {
13840            let (bytes, root, subject) = proven_fan_in(k);
13841            let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
13842            let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
13843            println!(
13844                "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
13845                took.as_secs_f64()
13846            );
13847            assert_eq!(verdict, Ok(()), "{what}");
13848        }
13849    }
13850    include!("minutes_tests.rs");
13851    include!("memo_tests.rs");
13852    include!("absence_tests.rs");
13853    include!("route_fault_tests.rs");
13854}