bsv_overlay_engine/engine.rs
1//! Overlay Services Engine — the core orchestrator.
2//!
3//! Receives transactions (submit), answers queries (lookup), manages advertisements,
4//! and coordinates GASP sync. All storage, topic management, and lookup service
5//! operations go through trait interfaces.
6//!
7//! Ported from `~/bsv/overlay-services/src/Engine.ts` (1,337 lines).
8
9use crate::beef_limits;
10
11use bsv_rs::transaction::{Beef, Transaction};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use tracing::{error, info, warn};
15
16use crate::advertiser::{Advertiser, AdvertiserError};
17use crate::broadcaster::{ArcBroadcaster, Broadcaster};
18use crate::lookup_service::{LookupService, LookupServiceError};
19use crate::storage::{Storage, StorageError};
20use crate::topic_manager::{TopicManager, TopicManagerError};
21use crate::types::*;
22
23/// Controls how much spend history to include in lookup responses.
24///
25/// Maps to the TS `historySelector` parameter which can be a number
26/// (depth limit) or an async function (per-output decider).
27#[derive(Debug, Clone)]
28pub enum HistorySelector {
29 /// Include up to N levels of ancestor spend history.
30 Depth(u32),
31}
32
33/// Configuration for constructing the Engine.
34pub struct EngineConfig {
35 /// URL where this engine is hosted. Required for advertisement sync.
36 pub hosting_url: Option<String>,
37 /// Known SHIP tracker URLs for bootstrapping.
38 pub ship_trackers: Vec<String>,
39 /// Known SLAP tracker URLs for bootstrapping.
40 pub slap_trackers: Vec<String>,
41 /// Configuration for GASP topic synchronization.
42 pub sync_configuration: SyncConfiguration,
43 /// Whether to suppress default SHIP/SLAP sync advertisements.
44 pub suppress_default_sync_advertisements: bool,
45}
46
47impl Default for EngineConfig {
48 fn default() -> Self {
49 Self {
50 hosting_url: None,
51 ship_trackers: Vec::new(),
52 slap_trackers: Vec::new(),
53 sync_configuration: HashMap::new(),
54 suppress_default_sync_advertisements: true,
55 }
56 }
57}
58
59/// The Overlay Services Engine.
60///
61/// Orchestrates topic managers, lookup services, storage, and advertisements.
62/// Does NOT include an HTTP server — that's the job of overlay-cloudflare.
63pub struct Engine {
64 managers: HashMap<String, Box<dyn TopicManager>>,
65 lookup_services: HashMap<String, Box<dyn LookupService>>,
66 storage: Box<dyn Storage>,
67 advertiser: Option<Box<dyn Advertiser>>,
68 broadcaster: Option<Box<dyn Broadcaster>>,
69 arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
70 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
71 gasp_remote_factory: Option<Box<dyn crate::gasp::GASPRemoteFactory>>,
72 /// OPT-IN / OFF BY DEFAULT chain-backed ancestor fetcher for GASP ingest.
73 /// When `None` (default / production), GASP ingest is byte-identical to
74 /// today. A platform crate may set `Some` (one-time-migration only) to let
75 /// ingest self-heal missing ancestry by fetching it from chain.
76 ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
77 /// bsv-low#302: per-peer GASP sync wall-clock budget —
78 /// `(sleep factory, budget ms)`. `None` (default) = unbounded per-peer
79 /// sync, byte-identical to the pre-#302 behavior. When set, each peer's
80 /// `GASPSync::sync` is raced against `sleep(budget_ms)`; a peer that
81 /// exceeds the budget is DROPPED (loud log, failure recorded, cursor NOT
82 /// advanced) and the loop continues with the next peer.
83 peer_sync_budget: Option<(SleepFactory, u64)>,
84 /// The per-GRAPH GASP budget (bsv-low #555): the sleep, the calls, the
85 /// ms. `None`: no graph is deferred.
86 graph_budget: Option<(SleepFactory, u32, u64)>,
87 /// The per-graph budget's bytes and nodes limbs (bsv-low #586,
88 /// [`Engine::set_graph_budget_limbs`]): in force once a graph budget is
89 /// set.
90 graph_budget_limbs: (u64, u32),
91 /// The bound of one transaction's finalize submit (bsv-low #559), see
92 /// [`Engine::set_finalize_submit_budget`].
93 finalize_submit_budget: Option<(SleepFactory, u64)>,
94 /// Open around each GASP finalize submit, so no deadline that races the
95 /// sync drops a transaction between its writes (bsv-low #552, the lens
96 /// fold's HIGH-1). See [`Engine::finalize_submit_gate`].
97 finalize_gate: crate::gasp::SubmitGate,
98 /// (txid, topic) pairs whose Phase-3 writes FAULTED in this engine and
99 /// have not landed since (bsv-low #559): a successor that finds no coin
100 /// while its predecessor is in here is not recorded as applied. One
101 /// invocation's memory; across invocations the same question is put to
102 /// the store ([`Engine::unlanded_predecessor`]).
103 not_landed: std::cell::RefCell<HashSet<(String, String)>>,
104 /// Asked before the door lands a carried predecessor (bsv-low #575, the
105 /// E1D delta-2 fold, L2). See [`Engine::set_landing_guard`].
106 landing_guard: Option<LandingGuard>,
107 /// Reference-parity spend verification on submit (2026-09-08). `true`
108 /// (DEFAULT): every submit outside `HistoricalTxNoSpv` runs the
109 /// reference's `Transaction.verify` walk: merkle paths against the chain
110 /// tracker AND every input's unlocking script EXECUTED, recursively over
111 /// the unproven ancestry in the BEEF; with no chain tracker the walk is
112 /// the ts-sdk's `'scripts only'` (roots accepted unchecked, scripts still
113 /// run). `false` is an ESCAPE HATCH, not a mode: it restores the
114 /// pre-2026-09-08 structural check (`Beef::verify_valid` + every root
115 /// against the tracker; NOTHING without a tracker), which admitted an
116 /// invalid spend on structure alone. See
117 /// [`Engine::set_script_verification`].
118 verify_scripts: bool,
119 /// The two limbs of the submit's own script walk (bsv-low #592): the
120 /// door's [`DoorBudget`], [`DoorBudget::DEFAULT`] unless set. See
121 /// [`Engine::set_walk_budget`].
122 walk_budget: DoorBudget,
123 config: EngineConfig,
124}
125
126/// Boxed platform sleep future (bsv-low#302). Not `Send` — the engine runs
127/// single-threaded on wasm (Workers) and under `#[tokio::test]` locally.
128pub type SleepFuture = std::pin::Pin<Box<dyn std::future::Future<Output = ()>>>;
129
130/// Platform sleep factory (bsv-low#302): milliseconds → a future that
131/// resolves after that delay. The Cloudflare crate passes its `sleep_ms`;
132/// tests pass `ready(())` (instant deadline) or `pending()` (no deadline)
133/// to drive the race deterministically.
134pub type SleepFactory = std::rc::Rc<dyn Fn(u64) -> SleepFuture>;
135
136/// The answer of a [`LandingGuard`]: `Ok` lets the body land, `Err` says why
137/// it may not (the topic waits).
138pub type LandingGuardFuture =
139 std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
140
141/// The caller's admission predicate over a carried predecessor the door is
142/// about to land: `(predecessor txid, topic)`. See
143/// [`Engine::set_landing_guard`].
144pub type LandingGuard = std::rc::Rc<dyn Fn(&str, &str) -> LandingGuardFuture>;
145
146/// The bound of ONE GASP finalize submit's storage calls and hooks (bsv-low
147/// #559, the lens fold of 2026-10-07, F2). The submit itself is never
148/// dropped: it is the write section no deadline drops, and c9921ee, which
149/// raced the whole submit against its budget inside the section, dropped it
150/// between two writes all the same. Each storage call, lookup hook and topic
151/// manager call inside it is raced against the section's ONE deadline
152/// instead, and a call that has not answered when the deadline falls due is
153/// dropped ALONE and reported as that call's fault: the submit runs on to
154/// its end as for any storage fault. Once the deadline is due no further
155/// call is started (each answers "no answer" at once), so a transaction
156/// costs at most its budget, and once more for the undo
157/// ([`CallBound::renew`]).
158struct CallBound {
159 sleep: SleepFactory,
160 budget_ms: u64,
161 deadline: std::cell::RefCell<SleepFuture>,
162 due: std::cell::Cell<bool>,
163}
164
165impl CallBound {
166 fn new(sleep: &SleepFactory, budget_ms: u64) -> Self {
167 Self {
168 deadline: std::cell::RefCell::new(sleep(budget_ms)),
169 sleep: sleep.clone(),
170 budget_ms,
171 due: std::cell::Cell::new(false),
172 }
173 }
174
175 /// A fresh allowance of the same length, for the undo of a faulted
176 /// submit: the calls that put the store back as it was found must not be
177 /// refused because the deadline the fault answered to has passed.
178 fn renew(&self) {
179 *self.deadline.borrow_mut() = (self.sleep)(self.budget_ms);
180 self.due.set(false);
181 }
182
183 /// Whether a call under this bound went unanswered (or was not started
184 /// because one had) since the last [`CallBound::renew`].
185 fn is_due(&self) -> bool {
186 self.due.get()
187 }
188
189 /// `call`, or `None` when the deadline fell due first.
190 async fn call<T>(&self, call: impl std::future::Future<Output = T>) -> Option<T> {
191 if self.due.get() {
192 return None;
193 }
194 let mut call = std::pin::pin!(call);
195 std::future::poll_fn(|cx| {
196 if let std::task::Poll::Ready(v) = call.as_mut().poll(cx) {
197 return std::task::Poll::Ready(Some(v));
198 }
199 if self.deadline.borrow_mut().as_mut().poll(cx).is_ready() {
200 self.due.set(true);
201 return std::task::Poll::Ready(None);
202 }
203 std::task::Poll::Pending
204 })
205 .await
206 }
207}
208
209/// `call` under `bound` (or unbounded with none); `Err` is "no answer".
210async fn bounded<T>(
211 bound: Option<&CallBound>,
212 call: impl std::future::Future<Output = T>,
213) -> Result<T, String> {
214 match bound {
215 Some(bound) => bound
216 .call(call)
217 .await
218 .ok_or_else(|| format!("no answer within {} ms", bound.budget_ms)),
219 None => Ok(call.await),
220 }
221}
222
223/// A storage call under `bound`: no answer in time is that call's fault.
224async fn stored<T>(
225 bound: Option<&CallBound>,
226 call: impl std::future::Future<Output = Result<T, StorageError>>,
227) -> Result<T, StorageError> {
228 match bounded(bound, call).await {
229 Ok(answer) => answer,
230 Err(no_answer) => Err(StorageError::Database(no_answer)),
231 }
232}
233
234/// A lookup hook under `bound`, its error as the report carries it.
235async fn hooked(
236 bound: Option<&CallBound>,
237 call: impl std::future::Future<Output = Result<(), LookupServiceError>>,
238) -> Result<(), String> {
239 bounded(bound, call).await?.map_err(|e| e.to_string())
240}
241
242/// The most store reads [`Engine::unlanded_predecessor`] makes for one
243/// topic of one submit (bsv-low #559, F3): the question is asked of every
244/// transaction that admits nothing and found no coin, so it is kept cheap.
245/// Counted are the reads spent on a body the BEEF does not prove and the
246/// store does not hold as landed (the second delta fold of 2026-10-07, M2);
247/// the 17th of those is never made: out of reads is "not now" (the delta
248/// fold of 2026-10-07, M2). An unproven body still needs room under it for
249/// the reads that find it landed (one for an applied row, two for a held
250/// output): each is tested before it is made.
251const PREDECESSOR_READS: usize = 16;
252
253/// The most store reads [`Engine::unlanded_predecessor`] makes for one
254/// SUBMIT (the third delta fold of 2026-10-07, M1): every read of the
255/// question, counted against [`PREDECESSOR_READS`] or not (those of proven
256/// and landed bodies included), in every topic of the submit together. The
257/// 257th is never made: past the allowance the answer is "not now", never
258/// "landed". Without it the question read as far as the BEEF reached (one
259/// read per 41 bytes: 66,305 reads measured from one 2.7 MB BEEF), on a
260/// public route. Per submit and not per topic because what it protects, the
261/// invocation's D1 allowance, is per invocation; 256 is the door's own unit
262/// ([`DoorBudget`]'s inputs per transaction), no derived figure.
263const PREDECESSOR_READS_PER_SUBMIT: usize = 256;
264
265/// Why a successor must wait ([`Engine::unlanded_predecessor`]), and the
266/// unlanded predecessor whose body the BEEF carries, when the answer names
267/// one: the door lands it first ([`Engine::land_carried`]).
268/// A submit's STEAK and report, and the carried predecessor that blocks each
269/// topic of a carried predecessor's own submit ([`Engine::submit_counted`]).
270type Counted = (Steak, MutationReport, HashMap<String, String>);
271
272/// What the landings of carried predecessors take from their successor's
273/// submit (the E592 lens fold, M1): its breach policy, and whether its own
274/// walk RAN (only then are the bodies it reached trusted).
275#[derive(Debug, Clone, Copy)]
276struct Landing {
277 policy: WalkBreachPolicy,
278 subject_walk_ran: bool,
279}
280
281struct NotNow {
282 why: String,
283 carried: Option<String>,
284}
285
286/// Summary of an [`Engine::complete_missing_proofs`] pass.
287///
288/// All counts are over the single bounded page scanned this tick. A no-fetcher
289/// (production) build returns the all-zero default.
290#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
291pub struct ProofCompletionSummary {
292 /// Stored transactions inspected this tick (the bounded page size).
293 pub scanned: usize,
294 /// Of `scanned`, how many still lacked a merkle proof for their own tx.
295 pub proofless: usize,
296 /// Proofless txs whose BUMP was fetched + stitched into the stored BEEF.
297 pub completed: usize,
298 /// Proofless txs the fetcher could serve but that are not yet mined
299 /// (no BUMP yet) — retried on a later tick.
300 pub still_unconfirmed: usize,
301 /// Proofless txs the fetcher errored on (e.g. budget exhausted, 429) —
302 /// retried on a later tick.
303 pub fetch_failed: usize,
304 /// Proofless txs whose proof came back but failed to stitch into storage.
305 pub stitch_failed: usize,
306 /// Scanned rows that ALREADY carried a valid proof (stale `has_proof = 0`
307 /// flag) and were marked proven this tick so they drop out of the candidate
308 /// window — clearing the window-clog (#130).
309 pub already_proven: usize,
310}
311
312/// Outcome of one [`Engine::sync_advertisements`] run (bsv-low #320 defect
313/// 3a).
314///
315/// The engine used to swallow create/submit failures into `error!` and
316/// return `Ok(())`, so the admin route reported `success` while zero
317/// advertisements were admitted locally; a caller could not tell a
318/// converged no-op (`to_create == 0`) from a silent failure. Every field
319/// here is observational — the sync's behavior is unchanged, its outcome is
320/// no longer hidden.
321#[derive(Debug, Default, Clone, Serialize)]
322#[serde(rename_all = "camelCase")]
323pub struct SyncAdvertisementsReport {
324 /// Advertisements the diff found missing (attempted this run). A
325 /// converged node reports 0 here.
326 pub to_create: usize,
327 /// Stale advertisements the diff said to revoke.
328 pub to_revoke: usize,
329 /// `advertiser.create_advertisements` failure, verbatim.
330 #[serde(skip_serializing_if = "Option::is_none")]
331 pub create_error: Option<String>,
332 /// Local `Engine::submit` failure for the created ads, verbatim — the
333 /// path that silently hid the self-admission failure live (our own
334 /// ls_ship/ls_slap never gained our ads, so every cycle re-created all
335 /// of them).
336 #[serde(skip_serializing_if = "Option::is_none")]
337 pub submit_error: Option<String>,
338 /// Outputs admitted per topic by the local submit (from the STEAK).
339 /// A successful submit that admitted NOTHING (topic-manager refusal)
340 /// shows up here as explicit zeros.
341 pub admitted: std::collections::BTreeMap<String, usize>,
342 /// `advertiser.find_all_advertisements` failure, verbatim (#320 M2).
343 /// When set, creation was REFUSED this run: a blind read must never
344 /// become "current ads = none" and re-create (re-pay) the entire set.
345 #[serde(skip_serializing_if = "Option::is_none")]
346 pub lookup_error: Option<String>,
347 /// Stale ads the diff wanted revoked but the advertiser declined to
348 /// build a revocation for (the CF advertiser's documented v1 no-op —
349 /// #320 L2). Distinguishes "revoke happened" from "revoke skipped".
350 pub revoke_skipped: usize,
351 /// `advertiser.revoke_advertisements` failure, verbatim.
352 #[serde(skip_serializing_if = "Option::is_none")]
353 pub revoke_error: Option<String>,
354 /// Local `Engine::submit` failure for the revocation, verbatim.
355 #[serde(skip_serializing_if = "Option::is_none")]
356 pub revoke_submit_error: Option<String>,
357}
358
359impl SyncAdvertisementsReport {
360 /// True when nothing errored. NOTE: a submit that admitted zero outputs
361 /// is not an error at this layer — that refusal is [`Self::effective`]'s
362 /// job.
363 #[must_use]
364 pub fn ok(&self) -> bool {
365 self.create_error.is_none()
366 && self.submit_error.is_none()
367 && self.lookup_error.is_none()
368 && self.revoke_error.is_none()
369 && self.revoke_submit_error.is_none()
370 }
371
372 /// Total outputs admitted by the local submit across topics.
373 #[must_use]
374 pub fn admitted_total(&self) -> usize {
375 self.admitted.values().sum()
376 }
377
378 /// True when the run errored nowhere AND the local submit admitted
379 /// EVERY created ad (#320 M1 + delta D1): each advertisement is exactly
380 /// one output and change is never a SHIP/SLAP token, so full admission
381 /// means `admitted_total() >= to_create` (defensive `>=`). A weaker
382 /// `> 0` bar let a PARTIAL admit pass — the live set is 9 SHIP + 8 SLAP
383 /// in ONE tx with independent per-topic admission, so tm_ship admitting
384 /// its 9 while tm_slap refused all 8 read as success while the diff
385 /// re-created (re-paid for) the refused 8 every cycle. Subsumes the
386 /// converged no-op (`0 >= 0`).
387 #[must_use]
388 pub fn effective(&self) -> bool {
389 self.ok() && self.admitted_total() >= self.to_create
390 }
391}
392
393/// Internal result from Phase 1 + 2 validation.
394///
395/// Carries per-topic admittance decisions, previous coins, and the parsed
396/// transaction so that Phase 3 (mutations) can proceed without re-parsing.
397struct TopicValidation {
398 topic: String,
399 is_dupe: bool,
400 /// Input indices that spend previously-admitted outputs from this topic.
401 previous_coins: Vec<u32>,
402 /// The previous outputs found in storage (parallel to previous_coins).
403 previous_outputs: Vec<Output>,
404 admittance: AdmittanceInstructions,
405 failed: bool,
406 /// The FIRST storage read fault hit while scanning this topic's
407 /// previous outputs (S2, bsv-low 2026-08-29). A faulted read makes a
408 /// spend look like it consumes nothing — the settle's spend pointer is
409 /// then never written — so it is carried into the [`MutationReport`]
410 /// and blocks the `applied_transactions` record for this topic: a
411 /// replay re-reads instead of being deduplicated away. With the read
412 /// that faulted: `find_output` (a previous coin) or
413 /// `does_applied_transaction_exist` (the dedup read, the delta fold of
414 /// 2026-10-07, H3).
415 read_fault: Option<(&'static str, String)>,
416}
417
418/// One Phase-3 write — or the validation read it depends on — that FAILED
419/// during a submit (S2 queue-durable admission, bsv-low 2026-08-29).
420///
421/// Before this existed every such failure was `error!`-logged and swallowed
422/// while `submit` returned `Ok(steak)`: under a D1-overload storm the
423/// overlay ACKED admissions whose writes never landed (the 2026-08-26
424/// phantom class — `c4d2ed06…`/`62c2a9b3…` absent from the engine store,
425/// beta D1 queried directly). A fault is now a VALUE the caller can act on
426/// (re-queue for an idempotent replay) instead of a log line nobody reads.
427#[derive(Debug, Clone, PartialEq, Eq)]
428pub struct MutationFault {
429 /// The topic whose mutation faulted.
430 pub topic: String,
431 /// The write (or read) that failed: `insert_output`,
432 /// `mark_utxo_as_spent`, `update_consumed_by`, `delete_utxo_deep`,
433 /// `insert_applied_transaction`, `find_output` (validation read),
434 /// `does_applied_transaction_exist` (the dedup read),
435 /// `lookup_service.output_spent`, `lookup_service.output_admitted_by_topic`,
436 /// `undo_insert_output` (the undo of a faulted submit's inserts),
437 /// `record_spent_coin_applied` (the applied row a spender writes for the
438 /// transaction of a coin it deletes), `predecessor_not_landed`.
439 pub site: &'static str,
440 /// The backend's own error text.
441 pub error: String,
442}
443
444/// What Phase 3 actually landed for a submit — the durability report.
445///
446/// `applied_topics` are the topics whose EVERY write (and lookup-service
447/// notification) succeeded and were therefore recorded in
448/// `applied_transactions`. A topic with any fault is deliberately NOT
449/// recorded: the dedup in Phase 1 would otherwise turn a replay into a
450/// no-op and make the loss permanent (the ordering was already
451/// write-then-record; the record is now conditional on the writes).
452#[derive(Debug, Default, Clone, PartialEq, Eq)]
453pub struct MutationReport {
454 pub faults: Vec<MutationFault>,
455 pub applied_topics: Vec<String>,
456 /// Topics this (txid, topic) pair had ALREADY been applied under — the
457 /// Phase-1 dedup skipped them, so their STEAK entry is an empty default
458 /// that means "nothing new", never "judged another tx" (loop-3 client
459 /// belt re-presents read the empty STEAK as index-pending).
460 pub deduped_topics: Vec<String>,
461 /// The predecessors the door LANDED first from this submit's BEEF, as
462 /// `(txid, topic)`, ancestors first ([`Engine::land_carried`]; the E1D
463 /// delta fold, L2). Each is an admission write of its own that no
464 /// caller asked for: a caller that guards its admission writes (the
465 /// worker's eviction ledger, bsv-low #513) guards these too.
466 pub landed_predecessors: Vec<(String, String)>,
467 /// The submit's own script walk could not run within the engine's
468 /// budget (bsv-low #592) and the submit went on as under
469 /// `HistoricalTxNoSpv` ([`WalkBreachPolicy::NetworkAccepted`] only; under
470 /// `NotNow` a breach is [`EngineError::WalkCouldNotRun`]). `None` when it
471 /// ran (or the mode does not walk). A caller counts it; it never makes a
472 /// report undurable.
473 pub walk_could_not_run: Option<WalkCouldNotRun>,
474 /// The walks of the carried predecessors this submit tried to land
475 /// ([`Engine::land_carried`]) that could not run within the budget (the
476 /// E592 lens fold, L3): under `NotNow` each one's landing did not happen
477 /// (the successor waits), under `NetworkAccepted` each went on. Counted
478 /// by the caller beside `walk_could_not_run`.
479 pub landed_walks_could_not_run: Vec<WalkCouldNotRun>,
480}
481
482/// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): what [`Engine::renotify_admitted`] did.
483#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
484pub struct RenotifyReport {
485 /// stored outputs of (txid, topic) the engine had ALREADY admitted
486 pub outputs: u32,
487 /// lookup-service notifications that returned Ok (outputs × services)
488 pub notified: u32,
489 /// notifications that failed — `<service>: <error>`; the others still ran
490 pub faults: Vec<String>,
491 /// the output indexes re-notified (the caller's spend discovery keys on them)
492 pub vouts: Vec<u32>,
493}
494
495impl MutationReport {
496 fn fault(&mut self, topic: &str, site: &'static str, error: String) {
497 self.faults.push(MutationFault {
498 topic: topic.to_string(),
499 site,
500 error,
501 });
502 }
503
504 /// True when every mutation this submit needed landed (nothing to
505 /// replay). A dupe-only submit is durable (there was nothing to write).
506 #[must_use]
507 pub fn is_durable(&self) -> bool {
508 self.faults.is_empty()
509 }
510
511 /// One log line naming every fault (`topic/site: error`), each error
512 /// clipped so a D1 stack dump cannot flood the log stream.
513 #[must_use]
514 pub fn summary(&self) -> String {
515 self.faults
516 .iter()
517 .map(|f| {
518 let err: String = f.error.chars().take(120).collect();
519 format!("{}/{}: {}", f.topic, f.site, err)
520 })
521 .collect::<Vec<_>>()
522 .join("; ")
523 }
524}
525
526/// The DOOR's static work bound (bsv-low W-A gate MED-1, 2026-09-09). Bitcoin
527/// script has no loops: every opcode runs at most once, so an input's work is
528/// bounded BEFORE execution by its opcode census times the interpreter's
529/// element limit (the stack memory limit bounds every element, and every
530/// CAT / NUM2BIN / SPLIT result with it) plus its signature checks times the
531/// transaction's own size or a floor, whichever is more (a BIP-143 preimage
532/// hashes the prevouts and the outputs; the EC verification costs the same
533/// whatever the transaction weighs). The estimate is computed from the bytes;
534/// nothing executes past the budget, and a budget breach is the DOOR's
535/// verdict (inconclusive: the network judges), never the interpreter's.
536///
537/// THE BODY IS NOT COUNTED (bsv-low #585, door 1). Until #585 the budget also
538/// counted the BODY: 64 unproven transactions, 256 inputs per transaction,
539/// 512 KB per transaction, and the parse's own size and counts. A valid BEEF
540/// is never left unjudged for its size or its counts, so those are gone: the
541/// walk reads the body through the streaming reader ([`crate::script_door`]).
542///
543/// TWO LIMBS, both a budget whose breach is "the network judges" and never a
544/// refusal: the WORK the interpreter will do, and the MEMORY the door will
545/// hold beside the caller's body. Each is estimated from the bytes before it
546/// is spent.
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub struct DoorBudget {
549 /// The interpreter's stack memory limit: bounds every element, and is the
550 /// unit of the work estimate (a hash-class opcode is charged one element).
551 /// Not a bound on the BEEF: a script that trips it is "the network
552 /// judges", as a breach of the work is.
553 pub memory_limit: usize,
554 /// The whole walk's estimated work, in bytes hashed or pushed.
555 pub max_work_bytes: u64,
556 /// The least a signature check is charged (the doors lens E585-D12-M1).
557 /// A check is charged `max(its transaction's bytes, this)`: the digest
558 /// hashes about the transaction, and the EC verification behind it costs
559 /// the same whatever the transaction weighs (about 95 us native). Charged
560 /// the transaction's bytes alone, a 106 byte `OP_CHECKSIG OP_NOT`
561 /// transaction bought a verification for 106 bytes of budget: 90,000 of
562 /// them walked at a fifth of the budget in 9.6 s. With the floor the
563 /// budget bounds the EC verifications of one walk at
564 /// `max_work_bytes / sig_check_floor`.
565 pub sig_check_floor: u64,
566 /// The whole walk's estimated memory beside the body, in bytes (the doors
567 /// lens E585-D12-L3): what the index, the stream's element in hand and
568 /// the walk's layout of a transaction hold, estimated from the frame's
569 /// lengths and counts by [`crate::stream_sizing::estimate`] BEFORE the
570 /// stream is opened; and, per input, what its two scripts hold once
571 /// parsed and run (the delta lens E585-D12-DELTA-M1: bsv-rs's chunk
572 /// records, their clones, a signature's subscript, the empty stack
573 /// entries the interpreter's memory limit does not count), charged from
574 /// the scripts' chunk count and bytes BEFORE either is parsed
575 /// (`script_door::script_parse_charge`, 512 bytes a chunk and 8 a byte).
576 ///
577 /// How the two compose: the frame's estimate is the most the walk holds
578 /// at any moment apart from the input being run (what is kept, and the
579 /// heaviest transaction in hand, counted once with its raw bytes and the
580 /// interpreter's raw copies of the unlocking script); the scripts' charge
581 /// is the EXPANSION of one input's scripts past those bytes, held while
582 /// that input runs and released after it. An input is run only while the
583 /// frame's estimate plus its own charge is within this limb.
584 pub max_memory_bytes: u64,
585}
586
587impl DoorBudget {
588 /// Sized for LOW's real shapes with a wide margin. What the static census
589 /// BOUNDS: hashing (hash-class ops × the element limit) and signature
590 /// checks (× the transaction's size or the floor, whichever is more; a
591 /// CHECKMULTISIG weighted by its static key count when the count is a
592 /// small-int push, else by the most keys the element limit admits).
593 ///
594 /// THE FLOOR, 64 KiB (half the hash charge), chosen against LOW's shapes
595 /// as the pin `door_low_shapes_sit_under_a_quarter_of_the_work_budget`
596 /// measures them (work, of the 64 MiB; memory, the frame's and the
597 /// largest input's scripts, of the 48 MiB limb; work before the floor in
598 /// brackets):
599 ///
600 /// | shape | inputs | work | memory |
601 /// |---|---|---|---|
602 /// | the real covenant settle (5 hash opcodes, 4 checks: a CHECKSIG and the stated `OP_3` CHECKMULTISIG) | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
603 /// | the real covenant refund | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
604 /// | LOW's JOIN: two seats (bsv-low `template_tx_sizes.rs:76`), the real pot lock, each coin 40 unproven P2PKH hops deep (the deepest the fleet's record names) | 82 | 16,132,638, 24.0 % | 67,026, 0.13 % |
605 /// | STRESS, not LOW's shape: nine seats, three hops each | 36 | 7,082,619, 10.6 % (4,740,838) | 36,420, 0.07 % |
606 /// | a coin 30 P2PKH hops deep | 30 | 5,902,188, 8.8 % (3,941,856) | 23,720, 0.05 % |
607 ///
608 /// A P2PKH input is charged 196,739 bytes (a hash opcode at
609 /// the element limit, a check at the floor, its 131 script bytes): 341 of
610 /// them are the budget, 85 a quarter of it. The bound on EC verifications
611 /// per walk is 64 MiB / 64 KiB = 1,024 (1,023 with their script bytes):
612 /// measured natively, 1,000 of them walk in 87 ms in the release profile
613 /// (1.4 to 1.8 s in a debug build), and the lens's 17,000 stop at the
614 /// 1,024th in 108 ms. wasm32 was not measured.
615 ///
616 /// THE MEMORY, 48 MiB: three eighths of a 128 MB isolate. Beside it stand,
617 /// for a 10 MB request (the figure the arithmetic is made at: since NL-6
618 /// the route caps no body, so a heavier one is the isolate's), the
619 /// request's body, the completed BEEF the route hands the door (a second
620 /// copy of up to as much) and the EF batch (the route's 2 MB bound, twice
621 /// while it is serialized): 24 MB, which leaves 56 MB for the module, the
622 /// runtime, the allocator's fragmentation and what a native estimate does
623 /// not see of wasm32. The limb bounds the walk (the door's, and since
624 /// bsv-low #592 the submit's own and the GASP anchor's under
625 /// [`Engine::set_walk_budget`]) and nothing else: the route's own
626 /// hydrated parses before it are bounded by the isolate only (the land
627 /// lens E585-LAND-M1: 1.43 GB natively for a 9.9 MB body of 900,000
628 /// minimal transactions, before the route's 429). What
629 /// it lets through, by the door's charges (`script_door::DOOR_CHARGES`,
630 /// each an upper bound: the pins measure 1.3 to 5.3 times the heap the
631 /// walk reaches): a BUMP of up to 72,944 leaves (690 bytes a level-0
632 /// leaf); a single transaction of up to about 12 MB (4 bytes a byte)
633 /// whose walked inputs' scripts are few chunks (a script is charged 520
634 /// bytes a one-byte opcode and 8 a pushed byte: an input whose two
635 /// scripts are about 96,000 opcodes, or a 1 MB `OP_NOP` lock, is past
636 /// the limb before it is parsed, the network judges; LOW's covenant leg,
637 /// 6,608 script bytes, is charged 1.3 MB); a 10 MB body of one-input,
638 /// one-output P2PKH transactions (191 bytes, charged 570: 30 MB). What it
639 /// stops: 111,848 transactions of no input and no output, whatever
640 /// follows them.
641 ///
642 /// THE OPEN RESIDUAL (the W-A gate's delta-verify, 2026-09-09, measured):
643 /// the interpreter itself meters nothing, so two classes stay charged
644 /// only by their script bytes — bignum arithmetic (`OP_MUL`/`OP_DIV`/
645 /// `OP_MOD` on operands up to the element limit: ~1.3 ms per 30 KB×30 KB
646 /// round, linear in rounds) and `OP_NUM2BIN`, which
647 /// allocates its size operand BEFORE the memory limit is consulted (up to
648 /// bsv-rs's 1 GB element size: an isolate kill, not a refusal). None of
649 /// them can refuse a spend (a breach and a trip are the door's bound and
650 /// the request proceeds); all of them cost the beta overlay CPU until
651 /// bsv-rs meters work dynamically (a `work_limit` charged per op with the
652 /// real operand sizes, and a pre-allocation check in NUM2BIN) — OWED
653 /// before any PROD flip of `SCRIPT_VERIFY_NETWORK_GATED`. Since #585 the
654 /// count of such rounds is bounded by the body alone (the script bytes
655 /// are charged, the transactions and inputs are not counted). A third,
656 /// as before #585 (the doors lens N7): an opcode that copies an element
657 /// (`OP_DUP`, `OP_CAT`, `OP_PICK`) is charged its one script byte and can
658 /// copy up to the element limit. The class the floor CLOSED: a
659 /// CHECKMULTISIG whose key count is a computed value was charged the most
660 /// keys the element limit admits (3,971) at its transaction's bytes and
661 /// run (~80 µs per key tried); at the floor those are 260 MB, past the
662 /// budget, so such an input is never run at the door (the network
663 /// judges). LOW's covenant states its count (`OP_3`).
664 pub const DEFAULT: DoorBudget = DoorBudget {
665 memory_limit: 128 * 1024,
666 max_work_bytes: 64 * 1024 * 1024,
667 sig_check_floor: 64 * 1024,
668 max_memory_bytes: 48 * 1024 * 1024,
669 };
670}
671
672/// Which limb of the [`DoorBudget`] a walk went past.
673#[derive(Debug, Clone, Copy, PartialEq, Eq)]
674pub enum DoorLimb {
675 /// The static work estimate, or the interpreter's own memory limit
676 /// tripping inside one input.
677 Work,
678 /// The estimate of what the door holds beside the body.
679 Memory,
680}
681
682/// What [`Engine::run_validation`] answers: the topics' validations, the
683/// preliminary STEAK, the subject and its txid, and whether the walk could
684/// not run within the budget (bsv-low #592).
685type Validated = (
686 Vec<TopicValidation>,
687 Steak,
688 Transaction,
689 String,
690 Option<WalkCouldNotRun>,
691);
692
693/// Which limb of the [`DoorBudget`] a submit's own walk went past (bsv-low
694/// #592): the [`DoorLimb`] of the door's error, with the interpreter's own
695/// memory limit told apart from the static work estimate.
696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
697pub enum WalkLimb {
698 /// The static work estimate.
699 OverWork,
700 /// The estimate of what the walk holds beside the body.
701 OverMemory,
702 /// The interpreter's own memory limit (the budget's element limit)
703 /// tripping inside one input.
704 InterpreterMemory,
705}
706
707impl WalkLimb {
708 /// The door's word for the same limb ([`DoorLimb`]: the interpreter's
709 /// trip is `Work` there).
710 #[must_use]
711 pub fn door_limb(self) -> DoorLimb {
712 match self {
713 WalkLimb::OverWork | WalkLimb::InterpreterMemory => DoorLimb::Work,
714 WalkLimb::OverMemory => DoorLimb::Memory,
715 }
716 }
717
718 /// The limb's name in a log line and a counter's reason.
719 #[must_use]
720 pub fn as_str(self) -> &'static str {
721 match self {
722 WalkLimb::OverWork => "over_work",
723 WalkLimb::OverMemory => "over_memory",
724 WalkLimb::InterpreterMemory => "interpreter_memory",
725 }
726 }
727}
728
729/// What a submit does when its own script walk could not run within the
730/// engine's [`DoorBudget`] (bsv-low #592; the E592 lens fold, H1, the owner's
731/// ruling 3a read with the lens): a breach is NEVER an admission without the
732/// network's word on record.
733///
734/// The work limb is a static estimate from the bytes, and a LOCK can be made
735/// to pass it cheaply (a bare `OP_CHECKMULTISIG` in a fabricated parent's
736/// lock is charged 3,971 keys). So a breach says nothing about whether the
737/// spend is valid, and on a path where nothing else judges it an admission
738/// would admit a spend nobody checked: the lens's forged spend of a held coin.
739#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
740pub enum WalkBreachPolicy {
741 /// "Not now" ([`EngineError::WalkCouldNotRun`]): nothing judged, nothing
742 /// written, never a final refusal. Every door but one: the ungated
743 /// modes, `/admin/readmit`, the peer crawler, a library consumer's
744 /// submit; the GASP anchor HOLDS its UTXO the same way.
745 #[default]
746 NotNow,
747 /// The network's ACCEPT of these bytes is on record (the queue's replay
748 /// of a gated submission, whose producer broadcast them and marked the
749 /// message): the submit goes on exactly as under `HistoricalTxNoSpv`,
750 /// reported on [`MutationReport::walk_could_not_run`]. The roots the walk
751 /// reached are still checked, and a bad one is still the `SpvError` it is.
752 NetworkAccepted,
753}
754
755/// A submit whose own script walk COULD NOT RUN within the engine's
756/// [`DoorBudget`] (bsv-low #592, the owner's ruling 3a of 2026-10-10). Shaped
757/// like [`EngineError::ScriptWalkOverBudget`]. Under
758/// [`WalkBreachPolicy::NotNow`] it is carried by
759/// [`EngineError::WalkCouldNotRun`] (not now); under
760/// [`WalkBreachPolicy::NetworkAccepted`] the submit went on as under
761/// `HistoricalTxNoSpv` and this says so on its [`MutationReport`]. The
762/// reference (`Engine.submit`, `tx.verify`) has no budget; this is the
763/// platform's addition, on the door's lineage (D8).
764#[derive(Debug, Clone, PartialEq, Eq)]
765pub struct WalkCouldNotRun {
766 /// The submitted transaction.
767 pub subject_txid: String,
768 /// The transaction the walk stopped at.
769 pub at_txid: String,
770 /// Whether the subject's own inputs had all executed before the stop.
771 pub subject_judged: bool,
772 /// The limb it went past.
773 pub limb: WalkLimb,
774 /// The estimate against the limb, in the door's words.
775 pub what: String,
776}
777
778/// What the walk did (the door's cost instrument, since `Date.now()` is
779/// frozen during synchronous work on Workers and cannot time it).
780#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
781pub struct WalkStats {
782 /// Unproven transactions whose inputs were executed.
783 pub unproven_txs: usize,
784 /// Inputs executed (scripts run).
785 pub inputs_executed: usize,
786 /// Unlocking + locking script bytes executed.
787 pub script_bytes: usize,
788 /// Hash-class opcodes seen (SHA1/SHA256/HASH160/HASH256/RIPEMD160).
789 pub hash_ops: usize,
790 /// Signature checks seen (CHECKSIG counts 1, CHECKMULTISIG counts its
791 /// consensus maximum of 20).
792 pub sig_ops: usize,
793 /// The static work estimate charged against the budget.
794 pub work_bytes: u64,
795 /// The memory estimate charged against the budget: the frame's, made
796 /// before the stream was opened, plus the largest charge of one walked
797 /// input's scripts, made before they were parsed.
798 pub memory_bytes: u64,
799 /// Whether the SUBJECT's own inputs were all executed (an ancestor may
800 /// still have faulted afterwards).
801 pub subject_judged: bool,
802}
803
804/// Static opcode census of one input's scripts: (bytes, hash ops, sig ops).
805///
806/// A CHECKMULTISIG is weighted by its KEY COUNT: the small-int push that
807/// precedes it when the script states the count (the Poc5 covenant's
808/// `OP_3`), else the most 33-byte keys the element limit admits (the count is
809/// a computed value the census cannot see; bsv-rs allows up to `i32::MAX`).
810pub(crate) fn script_census(
811 unlocking: &[u8],
812 locking: &[u8],
813 memory_limit: usize,
814) -> Result<(usize, usize, usize), String> {
815 use bsv_rs::script::op::*;
816 let most_keys = (memory_limit / 33).max(1);
817 let mut hash_ops = 0usize;
818 let mut sig_ops = 0usize;
819 for (label, bytes) in [("unlocking", unlocking), ("locking", locking)] {
820 let script = bsv_rs::script::Script::from_binary(bytes)
821 .map_err(|e| format!("{label} script does not parse: {e}"))?;
822 let chunks = script.chunks();
823 for (i, chunk) in chunks.iter().enumerate() {
824 match chunk.op {
825 OP_RIPEMD160 | OP_SHA1 | OP_SHA256 | OP_HASH160 | OP_HASH256 => hash_ops += 1,
826 OP_CHECKSIG | OP_CHECKSIGVERIFY => sig_ops += 1,
827 OP_CHECKMULTISIG | OP_CHECKMULTISIGVERIFY => {
828 let stated = i
829 .checked_sub(1)
830 .map(|j| chunks[j].op)
831 .and_then(|op| match op {
832 OP_1..=OP_16 => Some((op - OP_1 + 1) as usize),
833 _ => None,
834 });
835 sig_ops += stated.unwrap_or(most_keys);
836 }
837 _ => {}
838 }
839 }
840 }
841 Ok((unlocking.len() + locking.len(), hash_ops, sig_ops))
842}
843
844impl Engine {
845 /// Page size used by `/requestSyncResponse` when the (public) caller
846 /// omits `limit`. A bounded page is not lossy for a conforming
847 /// initiator: ours ([`crate::gasp::GASPSync`]) keeps requesting pages
848 /// for as long as the `since` score cursor advances (gate finding M1 —
849 /// it must NOT require a "full" page to continue, because this clamp
850 /// makes a full-by-the-initiator's-limit page impossible).
851 pub const SYNC_RESPONSE_DEFAULT_LIMIT: u64 = 500;
852 /// Hard cap on a `/requestSyncResponse` page. Before bsv-low #291 an
853 /// absent/huge caller-supplied limit meant an unbounded scan of the
854 /// outputs table on a public route.
855 pub const SYNC_RESPONSE_MAX_LIMIT: u64 = 1000;
856
857 /// Clamp the public `/requestSyncResponse` page size: absent → the
858 /// default page; anything larger than the cap → the cap. The result is
859 /// ALWAYS `Some`-worthy — a sync response is never unbounded (bsv-low
860 /// #291). Completeness relies on the INITIATOR paging while its `since`
861 /// cursor advances (see `SYNC_RESPONSE_DEFAULT_LIMIT`'s doc) — the
862 /// responder alone cannot page for it.
863 pub fn clamp_sync_limit(requested: Option<u64>) -> u64 {
864 requested
865 .unwrap_or(Self::SYNC_RESPONSE_DEFAULT_LIMIT)
866 .min(Self::SYNC_RESPONSE_MAX_LIMIT)
867 }
868
869 /// Create a new Overlay Services Engine.
870 pub fn new(
871 managers: HashMap<String, Box<dyn TopicManager>>,
872 lookup_services: HashMap<String, Box<dyn LookupService>>,
873 storage: Box<dyn Storage>,
874 advertiser: Option<Box<dyn Advertiser>>,
875 config: EngineConfig,
876 ) -> Self {
877 Self::with_chain_tracker(
878 managers,
879 lookup_services,
880 storage,
881 advertiser,
882 None,
883 None,
884 config,
885 )
886 }
887
888 /// Create a new Engine with an optional ChainTracker for SPV verification.
889 pub fn with_chain_tracker(
890 managers: HashMap<String, Box<dyn TopicManager>>,
891 lookup_services: HashMap<String, Box<dyn LookupService>>,
892 storage: Box<dyn Storage>,
893 advertiser: Option<Box<dyn Advertiser>>,
894 broadcaster: Option<Box<dyn Broadcaster>>,
895 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
896 config: EngineConfig,
897 ) -> Self {
898 Self::with_all(
899 managers,
900 lookup_services,
901 storage,
902 advertiser,
903 broadcaster,
904 None,
905 chain_tracker,
906 config,
907 )
908 }
909
910 /// Create a new Engine with all optional components.
911 #[allow(clippy::too_many_arguments)]
912 pub fn with_all(
913 managers: HashMap<String, Box<dyn TopicManager>>,
914 lookup_services: HashMap<String, Box<dyn LookupService>>,
915 storage: Box<dyn Storage>,
916 advertiser: Option<Box<dyn Advertiser>>,
917 broadcaster: Option<Box<dyn Broadcaster>>,
918 arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
919 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
920 mut config: EngineConfig,
921 ) -> Self {
922 // Build default sync configuration: SHIP for all topics except tm_ship/tm_slap
923 // which get their trackers merged with provided shipTrackers/slapTrackers.
924 for manager_name in managers.keys() {
925 if manager_name == "tm_ship" {
926 if matches!(
927 config.sync_configuration.get(manager_name),
928 Some(SyncTarget::Disabled)
929 ) {
930 continue;
931 }
932 let mut combined: HashSet<String> = HashSet::new();
933 if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
934 {
935 combined.extend(peers.iter().cloned());
936 }
937 combined.extend(config.ship_trackers.iter().cloned());
938 if !combined.is_empty() {
939 config.sync_configuration.insert(
940 manager_name.clone(),
941 SyncTarget::Peers(combined.into_iter().collect()),
942 );
943 }
944 } else if manager_name == "tm_slap" {
945 if matches!(
946 config.sync_configuration.get(manager_name),
947 Some(SyncTarget::Disabled)
948 ) {
949 continue;
950 }
951 let mut combined: HashSet<String> = HashSet::new();
952 if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
953 {
954 combined.extend(peers.iter().cloned());
955 }
956 combined.extend(config.slap_trackers.iter().cloned());
957 if !combined.is_empty() {
958 config.sync_configuration.insert(
959 manager_name.clone(),
960 SyncTarget::Peers(combined.into_iter().collect()),
961 );
962 }
963 } else if !config.sync_configuration.contains_key(manager_name) {
964 config
965 .sync_configuration
966 .insert(manager_name.clone(), SyncTarget::Ship);
967 }
968 }
969
970 Self {
971 managers,
972 lookup_services,
973 storage,
974 advertiser,
975 broadcaster,
976 arc_broadcaster,
977 chain_tracker,
978 gasp_remote_factory: None,
979 ancestor_fetcher: None,
980 peer_sync_budget: None,
981 graph_budget: None,
982 graph_budget_limbs: (
983 crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES,
984 crate::gasp::DEFAULT_GRAPH_BUDGET_NODES,
985 ),
986 finalize_submit_budget: None,
987 finalize_gate: crate::gasp::SubmitGate::default(),
988 not_landed: std::cell::RefCell::new(HashSet::new()),
989 landing_guard: None,
990 verify_scripts: true,
991 walk_budget: DoorBudget::DEFAULT,
992 config,
993 }
994 }
995
996 /// Set the ARC broadcaster for network broadcast to miners.
997 ///
998 /// When set, the Engine broadcasts transactions to ARC during Phase 2
999 /// of `submit()` for `CurrentTx` submissions.
1000 pub fn set_arc_broadcaster(&mut self, arc: Box<dyn ArcBroadcaster>) {
1001 self.arc_broadcaster = Some(arc);
1002 }
1003
1004 /// Set the GASP remote factory for peer communication during sync.
1005 ///
1006 /// Platform-specific crates (like overlay-cloudflare) provide an implementation
1007 /// that creates HTTP-based remotes using their native fetch API.
1008 pub fn set_gasp_remote_factory(&mut self, factory: Box<dyn crate::gasp::GASPRemoteFactory>) {
1009 self.gasp_remote_factory = Some(factory);
1010 }
1011
1012 /// Set the OPT-IN chain-backed ancestor fetcher for GASP ingest.
1013 ///
1014 /// **OFF BY DEFAULT.** When set, a peer's failure to serve a needed
1015 /// ancestor during GASP ingest falls back to fetching that ancestor's raw
1016 /// tx from chain (via the supplied fetcher), self-healing the graph and
1017 /// also enabling strict-BEEF finalize so missing ancestry fails loud.
1018 ///
1019 /// This is a deliberate one-time-migration escape hatch. Production must
1020 /// NOT call this — when unset, GASP ingest behavior is unchanged.
1021 pub fn set_ancestor_fetcher(&mut self, fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>) {
1022 self.ancestor_fetcher = Some(fetcher);
1023 }
1024
1025 /// Set the admission predicate asked BEFORE the door lands a carried
1026 /// predecessor (bsv-low #575, the E1D delta-2 fold, L2;
1027 /// [`Engine::land_carried`]). It is asked once per body the landing
1028 /// would submit, before that body's submit (a dry one included) and
1029 /// before its reads are charged; an `Err` (the caller's refusal, or its
1030 /// own read fault) ends the landing with nothing of that body written
1031 /// and the successor's topic answers "not now", as on `683dffd`. The
1032 /// worker installs its eviction ledger here: an OPEN eviction of the
1033 /// body refuses it. Its read is not one of the submit's 256 (it is the
1034 /// caller's store, at most one per landed body). Unset (default): every
1035 /// carried predecessor may land. What the predicate cannot see (a row
1036 /// opened while the landing writes) stays the caller's to guard after
1037 /// the write ([`MutationReport::landed_predecessors`]).
1038 pub fn set_landing_guard(&mut self, guard: LandingGuard) {
1039 self.landing_guard = Some(guard);
1040 }
1041
1042 /// Set the per-peer GASP sync budget (bsv-low#302).
1043 ///
1044 /// `sleep` is the platform sleep factory (ms → future); `budget_ms` is
1045 /// the wall-clock slice ONE peer's sync may consume. A peer exceeding it
1046 /// is dropped (loudly logged, listed in the topic's `errors`) and the
1047 /// loop moves on. Unset (default) = unbounded, the pre-#302 behavior.
1048 ///
1049 /// **Progress survives the deadline (bsv-low #552).** With a budget set,
1050 /// every graph is submitted AS IT FINALIZES, inside the raced future (the
1051 /// reference submits inside `finalizeGraph` too), so what was finalized
1052 /// before the deadline stays admitted. At the deadline the cursor is
1053 /// advanced to `GASPSync::completed_cursor`: past the UTXOs whose graphs
1054 /// were completed, never past the one in flight. That graph is lost
1055 /// whole (nothing of it was finalized) and the next tick walks it again,
1056 /// down to whatever is now in storage.
1057 ///
1058 /// **The deadline never lands inside a transaction's writes** (the lens
1059 /// fold's HIGH-1). It is cooperative around a finalize submit
1060 /// ([`Engine::finalize_submit_gate`]): a deadline that falls due while
1061 /// one transaction is being written waits for that transaction, and the
1062 /// sync is dropped at the boundary. What a dropped tick leaves of the
1063 /// graph being submitted is an ancestors-first prefix of WHOLE
1064 /// transactions, with the cursor below its UTXO. Everywhere else (a
1065 /// request to the peer, the walk, the anchor check) the deadline drops
1066 /// the sync at once, as a budget against a dead peer must. A dropped tick that finalized a
1067 /// graph or moved the cursor is recorded as a SUCCESSFUL attempt for the
1068 /// quarantine count: a peer serving a long bootstrap is not a dead peer.
1069 /// One with no progress is a failed attempt, as before.
1070 ///
1071 /// The budget bounds the work of a TICK. Without a per-graph budget
1072 /// nothing bounds a GRAPH (parity: the reference has no node cap): one
1073 /// graph whose own walk outlasts the budget is dropped on every tick
1074 /// (`deadline_dropped_graphs`) and never admitted. With one
1075 /// ([`Engine::set_graph_budget`], bsv-low #555) that walk is deferred
1076 /// with its progress kept, here too, and resumed next tick.
1077 pub fn set_peer_sync_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1078 self.peer_sync_budget = Some((sleep, budget_ms));
1079 }
1080
1081 /// Set the per-GRAPH GASP budget and turn DEFERRAL on (bsv-low #555).
1082 ///
1083 /// A graph whose walk makes `max_calls` requests (to the peer, or chain
1084 /// fetches) or runs `budget_ms` in one pass is DEFERRED: its partial walk
1085 /// (the nodes fetched with their proofs, the inputs still pending, the
1086 /// calls spent, its age in passes) is saved as ONE record of the storage
1087 /// ([`Storage::put_deferred_graph`], replaced on every deferral), its
1088 /// UTXO is held below the cursor as a failed one is (the gap guard) and
1089 /// not walked again in the sync (#554), and the sync goes on to the next
1090 /// UTXO and topic. The next sync that is served that UTXO RESUMES the
1091 /// walk from the record, asking only what is pending, under the same
1092 /// budget; a graph so converges over passes and is then completed as
1093 /// any graph is (#551's anchor check, the finalize submits, e1d's
1094 /// rules). A walk cut by the per-peer deadline is deferred the same way.
1095 ///
1096 /// Bounds: a record deferred [`crate::gasp::DEFERRED_GRAPH_MAX_PASSES`]
1097 /// times is dropped with its reason and the UTXO fails as before (the
1098 /// gap guard asks again, from the root); a record has no byte bound
1099 /// (bsv-low #585); at most
1100 /// [`crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC`] records per (peer,
1101 /// topic). Keep `budget_ms` below the per-peer budget so that a deep
1102 /// graph leaves the pass time for the UTXOs after it. Defaults offered:
1103 /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_CALLS`],
1104 /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_MS`].
1105 ///
1106 /// Every resume of one sync shares ONE such budget (the lens fold's M1),
1107 /// so the deferred UTXOs, served first, leave the pass the rest; a walk
1108 /// that fetched nothing keeps no record and is a failed attempt for the
1109 /// quarantine (H1); a walk that cannot be kept, or whose save faulted,
1110 /// goes on under the per-peer budget alone (L4, the delta fold's D-L2);
1111 /// a FRESH walk's error keeps no record (D-M1). With a budget, a sync the
1112 /// peer served work that finalized no graph and moved no cursor is
1113 /// YIELDLESS, and past [`crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED`] in a
1114 /// row AND [`crate::gasp::PEER_YIELDLESS_SECS_ALLOWED`] since the streak
1115 /// began each is a failed attempt (D-M2, the delta-2 fold's D2-M1; the
1116 /// streak is kept by [`Storage::record_peer_sync_yield`], keyed on the
1117 /// peer's [`crate::gasp::peer_origin`] as its quarantine is). A FRESH
1118 /// walk that faults after half its calls or half its time keeps its
1119 /// record (D2-L1); a RESUMED one that faults having appended nothing
1120 /// [`crate::gasp::DEFERRED_GRAPH_MAX_IDLE_FAULTS`] passes in a row is
1121 /// dropped (D2-M2). Each record says whether its peer is configured
1122 /// (`SyncTarget::Peers`), for a storage that reserves part of its
1123 /// ceiling for those (the worker reserves half).
1124 ///
1125 /// Unset (the default) nothing is deferred and the walk is the one
1126 /// before #555 (parity: the reference has no budget and no deferral).
1127 /// The storage must KEEP records (the four required deferred-graph
1128 /// methods of [`Storage`]): over one that refuses every save, every
1129 /// graph past the budget is walked from its root under the per-peer
1130 /// budget alone on every pass, as before #555, so do not set a budget
1131 /// there.
1132 ///
1133 /// The budget has two more limbs (bsv-low #586), in force with it at
1134 /// their defaults: the bytes one graph may be SERVED in one pass
1135 /// ([`crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES`], a budget per pass only:
1136 /// a record has no byte bound since bsv-low #585 door 4) and the nodes it
1137 /// may APPEND ([`crate::gasp::DEFAULT_GRAPH_BUDGET_NODES`], 64);
1138 /// [`Engine::set_graph_budget_limbs`] names others.
1139 pub fn set_graph_budget(&mut self, sleep: SleepFactory, max_calls: u32, budget_ms: u64) {
1140 self.graph_budget = Some((sleep, max_calls, budget_ms));
1141 }
1142
1143 /// The bytes and nodes limbs of the per-graph budget (bsv-low #586,
1144 /// zanaadu-v2 #377): a graph whose walk is SERVED `max_bytes_fetched`
1145 /// bytes in one pass (the hex of each node's raw transaction and proof,
1146 /// as the peer sends it and a record keeps it, every answer counted), or
1147 /// APPENDS `max_nodes` nodes, is DEFERRED exactly as at the call budget
1148 /// (reason `bytes` or `nodes`): its record saved, its UTXO held below
1149 /// the cursor, the walk resumed by the next pass from what is pending.
1150 /// Never a drop, a refusal or a discarded graph: these are budgets PER
1151 /// PASS, not limits on a graph or a BEEF. A pass always makes one
1152 /// request whatever a node's size; the resumes of one sync share one
1153 /// such budget, as they share its calls (M1); a walk that cannot be
1154 /// KEPT goes on under the per-peer budget alone (L4), as at the calls.
1155 /// They bound what one pass adds to the pending graph and to its record
1156 /// while the anchor check and the finalize hold it in memory. No effect
1157 /// until [`Engine::set_graph_budget`] turns deferral on; the reference
1158 /// has neither. This setter is UNCAPPED, as the worker's
1159 /// `GASP_GRAPH_BUDGET_BYTES` var is (bsv-low #585 door 4: the engine keeps
1160 /// no record cap for the limb to sit under). A consumer whose STORAGE caps
1161 /// a record must keep the bytes limb under that cap, or a pass whose
1162 /// record cannot be saved walks afresh (E586-L1).
1163 pub fn set_graph_budget_limbs(&mut self, max_bytes_fetched: u64, max_nodes: u32) {
1164 self.graph_budget_limbs = (max_bytes_fetched, max_nodes);
1165 }
1166
1167 /// Bound ONE transaction's GASP finalize submit (bsv-low #559, the delta
1168 /// lens's DELTA-3). That submit is the write section no deadline drops
1169 /// ([`Engine::finalize_submit_gate`]), so a storage call or lookup hook
1170 /// that never answers inside it held the per-peer budget and any outer
1171 /// race for as long as it hung. With this set EACH storage call, lookup
1172 /// hook and topic manager call of that submit is raced against one
1173 /// `sleep(budget_ms)` of the section (the lens fold of 2026-10-07, F2):
1174 /// a call still unanswered when it falls due is dropped alone and is
1175 /// that call's FAULT, and no call is started after it. The SUBMIT is
1176 /// never dropped: it runs to its end and leaves what a fault leaves
1177 /// ([`Engine::submit_with_report`]: nothing of the transaction, or all
1178 /// of it), the transaction did not land, the rest of its graph is not
1179 /// submitted, its UTXO fails and the cursor stays below it. The undo of
1180 /// a faulted submit has one more `sleep(budget_ms)` of its own, so a
1181 /// peer costs at most its budget plus twice this one. What the backend
1182 /// does with a statement whose caller stopped waiting is its own: a
1183 /// write that lands after its timeout is not seen by this submit.
1184 ///
1185 /// DEFAULT: none (a native engine has no clock). A caller that sets a
1186 /// per-peer budget should set this too.
1187 pub fn set_finalize_submit_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1188 self.finalize_submit_budget = Some((sleep, budget_ms));
1189 }
1190
1191 /// The gate around every GASP finalize submit (bsv-low #552, the lens
1192 /// fold's HIGH-1). `start_gasp_sync` races each peer against its budget
1193 /// with [`crate::gasp::race_or_deadline_guarded`] over this gate. A
1194 /// caller that races `start_gasp_sync` ITSELF against a deadline (the
1195 /// worker's scheduled step) must use the same function over the same
1196 /// gate: a plain `race_or_deadline` there can still drop a finalize
1197 /// submit between its writes.
1198 pub fn finalize_submit_gate(&self) -> &crate::gasp::SubmitGate {
1199 &self.finalize_gate
1200 }
1201
1202 /// Turn reference-parity script verification on submit on or off.
1203 /// **DEFAULT ON** (set in every constructor).
1204 ///
1205 /// On, every submit outside `HistoricalTxNoSpv` executes every unproven
1206 /// input's unlocking script against its source output (and checks every
1207 /// merkle path against the chain tracker), exactly as the reference's
1208 /// `Engine.submit` → `tx.verify(this.chainTracker)` does. Off is an
1209 /// ESCAPE HATCH for an operator who must admit a body the interpreter
1210 /// refuses (a bsv-rs interpreter defect, say) while it is fixed. It is
1211 /// NOT a mode: it restores the pre-2026-09-08 structural check, under
1212 /// which an invalid spend that no broadcaster had yet refused was
1213 /// admitted on BEEF structure alone. Prefer
1214 /// [`crate::builder::EngineBuilder::with_script_verification`].
1215 pub fn set_script_verification(&mut self, enabled: bool) {
1216 self.verify_scripts = enabled;
1217 }
1218
1219 /// Whether submits execute input scripts, see
1220 /// [`Engine::set_script_verification`]. `true` by default.
1221 pub fn script_verification(&self) -> bool {
1222 self.verify_scripts
1223 }
1224
1225 /// The budget of the submit's own script walk (bsv-low #592, the owner's
1226 /// ruling 3a of 2026-10-10). Every submit that walks (every mode but
1227 /// `HistoricalTxNoSpv`), and the GASP anchor check, read the BEEF through
1228 /// the door's stream walk ([`crate::script_door`]) under this budget's
1229 /// two limbs, the work and the memory, each estimated from the bytes
1230 /// before it is spent. A breach is "the walk could not run"
1231 /// ([`WalkCouldNotRun`], on [`MutationReport::walk_could_not_run`]),
1232 /// never a refusal: the submit goes on as under `HistoricalTxNoSpv`.
1233 /// A library consumer that sets nothing gets [`DoorBudget::DEFAULT`]
1234 /// (64 MiB of work, 48 MiB of memory). Prefer
1235 /// [`crate::builder::EngineBuilder::with_walk_budget`].
1236 pub fn set_walk_budget(&mut self, budget: DoorBudget) {
1237 self.walk_budget = budget;
1238 }
1239
1240 /// The budget of the submit's own script walk, see
1241 /// [`Engine::set_walk_budget`].
1242 pub fn walk_budget(&self) -> DoorBudget {
1243 self.walk_budget
1244 }
1245
1246 /// The reference's `tx.verify('scripts only')` over a BEEF: every unproven
1247 /// transaction from `subject_txid` down executes every input's unlocking
1248 /// script against its source output and obeys the value rule, while a
1249 /// transaction that carries a merkle path is TRUSTED AS-IS — no root is
1250 /// computed and no chain tracker is consulted (the ts-sdk's own shape when
1251 /// `chainTracker === 'scripts only'`: a proven tx is added to the verified
1252 /// set and the walk stops there).
1253 ///
1254 /// Built for an admission path whose bar is the NETWORK, not a proof
1255 /// (bsv-low's `broadcast-gated`, register row D1): the overlay broadcasts
1256 /// and admits on network evidence, so a lagging tracker must never read as
1257 /// a refused transaction — but a spend the interpreter refuses is one the
1258 /// network will refuse too, and executing it at the door saves the
1259 /// broadcast and names the fault. Independent of
1260 /// [`Engine::set_script_verification`] (that switch governs `submit`; this
1261 /// is an explicit ask) and of [`Engine::submit`]'s mode (`HistoricalTxNoSpv`
1262 /// still skips, as the reference does).
1263 ///
1264 /// Runs under the engine's budget ([`Engine::set_walk_budget`], the
1265 /// submit's own; [`DoorBudget::DEFAULT`] unless set: one budget per
1266 /// engine, the E592 lens fold's NOTE-2): the work is bounded statically
1267 /// before anything executes, and the interpreter's stack memory limit is
1268 /// the budget's element limit. The body is read through the streaming
1269 /// reader ([`crate::script_door`], bsv-low #585): no bound on its size,
1270 /// its transactions or a transaction's inputs or bytes; the budget's two
1271 /// limbs are the work and the memory beside the body.
1272 ///
1273 /// Errors: [`EngineError::ScriptVerificationFailed`] is the INTERPRETER's
1274 /// verdict and the only refusal; [`EngineError::ScriptWalkInconclusive`]
1275 /// is a structural fault (a transaction or source missing from the BEEF,
1276 /// a parse fault, the value rule) and [`EngineError::ScriptWalkOverBudget`]
1277 /// the door's own bound (the static budget or the memory limit) — both
1278 /// name whether the SUBJECT was judged before the fault, and a caller with
1279 /// a stronger bar behind it does not refuse on either.
1280 #[allow(
1281 clippy::unused_async,
1282 reason = "the door's callers await it on an engine; 'scripts only' asks no tracker"
1283 )]
1284 pub async fn verify_scripts_only(
1285 &self,
1286 beef_bytes: &[u8],
1287 subject_txid: &str,
1288 ) -> Result<WalkStats, EngineError> {
1289 crate::script_door::walk(beef_bytes, subject_txid, self.walk_budget)
1290 }
1291
1292 /// [`Engine::verify_scripts_only`] under a budget the caller names (the
1293 /// pins measure the door with a limb lifted; a caller with another
1294 /// platform's ceiling sets its own).
1295 #[allow(
1296 clippy::unused_async,
1297 clippy::unused_self,
1298 reason = "the twin of `verify_scripts_only`"
1299 )]
1300 pub async fn verify_scripts_only_under(
1301 &self,
1302 beef_bytes: &[u8],
1303 subject_txid: &str,
1304 budget: DoorBudget,
1305 ) -> Result<WalkStats, EngineError> {
1306 crate::script_door::walk(beef_bytes, subject_txid, budget)
1307 }
1308
1309 // ========================================================================
1310 // Submit — 3-phase pipeline
1311 // ========================================================================
1312
1313 /// Validate a tagged BEEF without applying mutations.
1314 ///
1315 /// Runs Phase 1 (topic validation) and Phase 2 (broadcast) but NOT Phase 3
1316 /// (storage mutations). Returns the Steak (admittance decisions) that
1317 /// `submit()` would return.
1318 ///
1319 /// Used by the onSteakReady pattern to return results to clients before
1320 /// mutations are applied via a queue consumer or `ctx.wait_until()`.
1321 ///
1322 /// The topic managers are called with `dry_run: true`
1323 /// (`TopicAdmittanceContext::DRY_RUN`): nothing is admitted on this call, so a
1324 /// manager that writes on admission leaves no trace of it.
1325 pub async fn submit_validate_only(
1326 &self,
1327 tagged_beef: &TaggedBEEF,
1328 mode: SubmitMode,
1329 ) -> Result<Steak, EngineError> {
1330 // A validate-only call admits nothing: a dry run by its own name. A
1331 // manager that writes on admission must not write here, or the real
1332 // submit that follows meets a head this call already advanced.
1333 let (_validations, steak, _tx, _txid, _could_not_run) = self
1334 .run_validation(
1335 tagged_beef,
1336 mode,
1337 &TopicAdmittanceContext::DRY_RUN,
1338 None,
1339 None,
1340 WalkBreachPolicy::NotNow,
1341 )
1342 .await?;
1343 Ok(steak)
1344 }
1345
1346 /// Submit a transaction for processing by the overlay.
1347 ///
1348 /// Three phases:
1349 /// 1. **VALIDATE** — check topics, dedup, find previous coins, call topic managers
1350 /// 2. **BROADCAST** — broadcast to network (if mode is CurrentTx)
1351 /// 3. **MUTATE** — mark spent, delete stale, insert new outputs, notify lookup services
1352 ///
1353 /// Returns a STEAK mapping each topic to its admittance instructions.
1354 ///
1355 /// Phase-3 faults are reported through [`Engine::submit_with_report`];
1356 /// this convenience keeps the historical signature and only LOGS the
1357 /// report's summary. A caller that must not ack an admission whose
1358 /// writes did not land (the `/submit` route — S2) uses the report form.
1359 pub async fn submit(
1360 &self,
1361 tagged_beef: &TaggedBEEF,
1362 mode: SubmitMode,
1363 ) -> Result<Steak, EngineError> {
1364 let (steak, report) = self.submit_with_report(tagged_beef, mode).await?;
1365 if !report.is_durable() {
1366 warn!(
1367 "submit: {} mutation fault(s) — not durable: {}",
1368 report.faults.len(),
1369 report.summary()
1370 );
1371 }
1372 Ok(steak)
1373 }
1374
1375 /// [`Engine::submit`] plus the Phase-3 durability report (S2
1376 /// queue-durable admission, bsv-low 2026-08-29).
1377 ///
1378 /// The STEAK is the admission DECISION; the report says whether the
1379 /// decision was DURABLY WRITTEN. They are returned together because
1380 /// they are answered by different things: Phase 1+2 decide, Phase 3
1381 /// lands, and only the caller knows whether an undurable landing may
1382 /// be acked (never, on the money path — re-queue it instead).
1383 ///
1384 /// Every write failure is still logged AND still non-fatal to the
1385 /// other topics (a faulted topic does not un-admit its siblings); what
1386 /// changed is that the failure is also VISIBLE, and that the faulted
1387 /// topic is not recorded as applied — so a replay of the same bytes is
1388 /// re-validated and re-written (every backend write is idempotent:
1389 /// `INSERT OR IGNORE` / `OR REPLACE` / `UPDATE`).
1390 ///
1391 /// What a fault leaves (bsv-low #559 and its lens fold of 2026-10-07):
1392 /// a topic's previous coins are deleted once EVERY admitted output of
1393 /// the transaction is inserted, and only then. A fault before that
1394 /// (a validation read, the spent mark of a coin the manager retains, an
1395 /// insert) leaves the previous coins held and nothing of the
1396 /// transaction: what did get inserted is taken out again, so the replay
1397 /// is the same judgement and no successor can spend half of it. A fault
1398 /// after it (a lookup notification, the consumed-by update, the applied
1399 /// row) leaves the transaction's outputs and no previous coin. A fault
1400 /// before any delete was started (the record of the spent coin's
1401 /// transaction, the read of the coin) is the first case if the coins
1402 /// are read back still held; once a delete was started its fault never
1403 /// undoes, answered or not (it may land yet): the outputs stay beside
1404 /// the stale coin, marked spent. A submit that deletes a stale coin
1405 /// first records that coin's transaction as applied, so a replay of it
1406 /// after its successor is a dupe. So a non-retaining chain never has
1407 /// two unspent heads and never none, whatever single call faults. The
1408 /// applied row is withheld on ANY fault. And a successor that was
1409 /// judged without the coin an unlanded predecessor has yet to leave is
1410 /// reported as a fault (`predecessor_not_landed`), not recorded as
1411 /// applied.
1412 ///
1413 /// A walk that could not run within the engine's budget is "not now"
1414 /// here ([`WalkBreachPolicy::NotNow`], [`EngineError::WalkCouldNotRun`]):
1415 /// never an admission. [`Engine::submit_with_report_under`] names the
1416 /// policy.
1417 pub async fn submit_with_report(
1418 &self,
1419 tagged_beef: &TaggedBEEF,
1420 mode: SubmitMode,
1421 ) -> Result<(Steak, MutationReport), EngineError> {
1422 self.submit_with_report_under(tagged_beef, mode, WalkBreachPolicy::NotNow)
1423 .await
1424 }
1425
1426 /// [`Engine::submit_with_report`] under an explicit
1427 /// [`WalkBreachPolicy`] (the E592 lens fold, H1): what a walk that could
1428 /// not run within the budget does. `NetworkAccepted` is for a caller that
1429 /// holds the network's accept of these very bytes (the queue's replay of
1430 /// a gated submission) and for nothing else: it admits a breached body
1431 /// with its scripts unrun.
1432 pub async fn submit_with_report_under(
1433 &self,
1434 tagged_beef: &TaggedBEEF,
1435 mode: SubmitMode,
1436 policy: WalkBreachPolicy,
1437 ) -> Result<(Steak, MutationReport), EngineError> {
1438 self.submit_bounded(tagged_beef, mode, None, false, policy)
1439 .await
1440 }
1441
1442 /// [`Engine::submit_with_report`] with every storage call, lookup hook
1443 /// and topic manager call under `bound` (the GASP finalize submit,
1444 /// [`CallBound`]); `None` is the unbounded submit of every other door.
1445 /// `finalize` is `true` for the GASP finalize submit alone, bounded or
1446 /// not: it does not ask the store's predecessor question.
1447 async fn submit_bounded(
1448 &self,
1449 tagged_beef: &TaggedBEEF,
1450 mode: SubmitMode,
1451 bound: Option<&CallBound>,
1452 finalize: bool,
1453 policy: WalkBreachPolicy,
1454 ) -> Result<(Steak, MutationReport), EngineError> {
1455 // The reads the store's predecessor question has made in this
1456 // submit, every topic together ([`PREDECESSOR_READS_PER_SUBMIT`]),
1457 // the predecessors the door lands first from the BEEF included.
1458 let mut question_reads = 0usize;
1459 self.submit_counted(
1460 tagged_beef,
1461 mode,
1462 bound,
1463 finalize,
1464 None,
1465 &mut question_reads,
1466 policy,
1467 )
1468 .await
1469 .map(|(steak, report, _)| (steak, report))
1470 }
1471
1472 /// [`Engine::submit_bounded`] over the submit's read allowance
1473 /// (`question_reads`), which a predecessor the door lands first from the
1474 /// BEEF shares with the successor that carried it (lane E1D's lens fold,
1475 /// M1). `carried` is `Some` for such a predecessor's own submit
1476 /// ([`Engine::land_carried`]), holding the bodies whose SPV walk already
1477 /// passed in this submit (none is walked twice; the delta fold, L4): it
1478 /// lands nothing first itself, and it names instead the carried
1479 /// predecessor that blocks each topic (the third value), so the landing
1480 /// is an explicit stack and never a recursion of submits (nested polls
1481 /// grow the stack, and a Worker's is small). Its manager is asked as a
1482 /// DRY RUN first, and for real only once its topic is not blocked: a
1483 /// link blocked below is judged twice and admitted once. Boxed: the
1484 /// landing is a submit of its own.
1485 #[allow(clippy::too_many_arguments)]
1486 fn submit_counted<'a>(
1487 &'a self,
1488 tagged_beef: &'a TaggedBEEF,
1489 mode: SubmitMode,
1490 bound: Option<&'a CallBound>,
1491 finalize: bool,
1492 carried: Option<&'a HashSet<String>>,
1493 question_reads: &'a mut usize,
1494 policy: WalkBreachPolicy,
1495 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Counted, EngineError>> + 'a>>
1496 {
1497 Box::pin(async move {
1498 self.submit_counted_inner(
1499 tagged_beef,
1500 mode,
1501 bound,
1502 finalize,
1503 carried,
1504 question_reads,
1505 policy,
1506 )
1507 .await
1508 })
1509 }
1510
1511 #[allow(clippy::too_many_arguments)]
1512 async fn submit_counted_inner(
1513 &self,
1514 tagged_beef: &TaggedBEEF,
1515 mode: SubmitMode,
1516 bound: Option<&CallBound>,
1517 finalize: bool,
1518 walked: Option<&HashSet<String>>,
1519 question_reads: &mut usize,
1520 policy: WalkBreachPolicy,
1521 ) -> Result<Counted, EngineError> {
1522 // A submit is a real admission, never a dry run; a carried
1523 // predecessor's is judged dry until its topic is known not to wait.
1524 let carried = walked.is_some();
1525 let context = if carried {
1526 TopicAdmittanceContext::DRY_RUN
1527 } else {
1528 TopicAdmittanceContext::default()
1529 };
1530 let (mut validations, mut steak, tx, txid, could_not_run) = self
1531 .run_validation(tagged_beef, mode, &context, bound, walked, policy)
1532 .await?;
1533 let mut report = MutationReport {
1534 walk_could_not_run: could_not_run,
1535 ..MutationReport::default()
1536 };
1537 // The body every LOOKUP SERVICE receives NAMES the subject (BRC-95
1538 // atomic prefix over the WHOLE submitted body — bsv-rs's
1539 // `to_binary_atomic` sorts, names, never prunes). Lookup services
1540 // re-parse it with `from_beef(_, None)`; on a plain incomplete body
1541 // that pick is the wire-last ANCESTOR, so the pot index's admit hook
1542 // shape-checked a hop and wrote no record while the engine had
1543 // admitted the pot (loop-2 fleet, 2026-09-05; the live proof after
1544 // the engine's own subject fix). A body that cannot be re-serialized
1545 // (never, for a parsed subject) is handed through as submitted.
1546 let subject_named_beef: Vec<u8> =
1547 beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
1548 .ok()
1549 .and_then(|mut b| b.to_binary_atomic(&txid).ok())
1550 .unwrap_or_else(|| tagged_beef.beef.clone());
1551
1552 // The successor rule, asked of every topic that found no coin before
1553 // anything of this submit is written ([`Engine::successors_waiting`]):
1554 // the topics that must wait, each with why.
1555 let mut blocked: HashMap<String, String> = HashMap::new();
1556 // The landing trusts the subject's walk only if it RAN (the E592
1557 // lens fold, M1): after a breach nothing it reaches was walked.
1558 let landing = Landing {
1559 policy,
1560 subject_walk_ran: report.walk_could_not_run.is_none(),
1561 };
1562 let waits = self
1563 .successors_waiting(
1564 &tx,
1565 &txid,
1566 &subject_named_beef,
1567 &mut validations,
1568 &mut steak,
1569 tagged_beef.off_chain_values.as_deref(),
1570 mode,
1571 bound,
1572 finalize,
1573 !carried,
1574 &mut blocked,
1575 question_reads,
1576 &mut report.landed_predecessors,
1577 landing,
1578 &mut report.landed_walks_could_not_run,
1579 )
1580 .await;
1581 // A carried predecessor's topic that does not wait is judged again,
1582 // for real: what it admits is this call's word, over the same coins.
1583 if carried {
1584 for v in &mut validations {
1585 if v.is_dupe || v.failed || v.read_fault.is_some() || waits.contains_key(&v.topic) {
1586 continue;
1587 }
1588 match self
1589 .judge(
1590 &tx,
1591 &v.topic,
1592 &v.previous_coins,
1593 tagged_beef.off_chain_values.as_deref(),
1594 mode,
1595 &TopicAdmittanceContext::default(),
1596 bound,
1597 )
1598 .await
1599 {
1600 Ok(admittance) => v.admittance = admittance,
1601 Err(e) => {
1602 error!("Error validating topic {} during submit: {e}", v.topic);
1603 v.failed = true;
1604 v.previous_coins.clear();
1605 v.previous_outputs.clear();
1606 v.admittance = AdmittanceInstructions::default();
1607 }
1608 }
1609 steak.insert(v.topic.clone(), v.admittance.clone());
1610 }
1611 }
1612
1613 // =================================================================
1614 // PHASE 3: MUTATE STORAGE
1615 // =================================================================
1616 for v in &validations {
1617 if v.is_dupe {
1618 report.deduped_topics.push(v.topic.clone());
1619 continue;
1620 }
1621 // A failed topic comes first, a read fault of it included: a
1622 // previous-coin read that faulted and a manager that then ERRED
1623 // is a failed topic with a durable report, no fault and no
1624 // replay. Parity: the reference fails the topic and never
1625 // replays. Nothing was written, so the store is as it was.
1626 if v.failed {
1627 continue;
1628 }
1629
1630 let topic = &v.topic;
1631 let admittance = &v.admittance;
1632 let faults_before = report.faults.len();
1633
1634 // A validation read that faulted: the manager judged without a
1635 // coin the store may hold, so NOTHING is written on that
1636 // judgement (the lens fold of 2026-10-07). Writing its outputs
1637 // would leave them beside the unseen coin, for a successor to
1638 // spend before the replay deletes it.
1639 if let Some((read, err)) = &v.read_fault {
1640 report.fault(topic, read, err.clone());
1641 self.hold_unapplied(&txid, topic, "a validation read faulted, nothing written");
1642 continue;
1643 }
1644
1645 // bsv-low #559 and lane E1D: a topic that found no coin while a
1646 // predecessor's landing is unknown is "not now", recorded
1647 // nowhere ([`Engine::successors_waiting`]).
1648 if let Some(not_now) = waits.get(topic) {
1649 report.fault(topic, "predecessor_not_landed", not_now.clone());
1650 self.hold_unapplied(
1651 &txid,
1652 topic,
1653 &format!("found no coin and its predecessor {not_now}"),
1654 );
1655 continue;
1656 }
1657
1658 // ── Handle stale vs retained previous coins ──
1659 let mut outputs_consumed: Vec<Outpoint> = Vec::new();
1660 let mut stale_coins: Vec<&Output> = Vec::new();
1661
1662 for (coin_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1663 let input_index = v.previous_coins[coin_idx];
1664 if admittance.coins_to_retain.contains(&input_index) {
1665 // Retained: track as consumed (will update consumedBy later)
1666 outputs_consumed
1667 .push(Outpoint::new(&prev_output.txid, prev_output.output_index));
1668 } else {
1669 // Not retained: mark as stale for deletion
1670 stale_coins.push(prev_output);
1671 }
1672 }
1673
1674 // Update STEAK with removed coins
1675 if let Some(steak_entry) = steak.get_mut(topic) {
1676 steak_entry.coins_removed = Some(
1677 stale_coins
1678 .iter()
1679 .enumerate()
1680 .filter_map(|(i, _)| {
1681 // Map back to input indices
1682 v.previous_coins.get(i).copied()
1683 })
1684 .collect(),
1685 );
1686 }
1687
1688 // ── Mark previous outputs as spent + notify lookup services ──
1689 // A mark that faults does not skip the notification (the
1690 // reference skips it): the coin is spent whatever the row says,
1691 // a stale one is deleted below and its lookup services must
1692 // know. The mark of a RETAINED coin is part of what landed: that
1693 // row stays, and left unspent it would be listed as a UTXO
1694 // beside the output that consumed it.
1695 let mut retained_mark_faulted = false;
1696 for (prev_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1697 if let Err(e) = stored(
1698 bound,
1699 self.storage.mark_utxo_as_spent(
1700 &prev_output.txid,
1701 prev_output.output_index,
1702 topic,
1703 ),
1704 )
1705 .await
1706 {
1707 error!("Error marking UTXO as spent: {e}");
1708 report.fault(topic, "mark_utxo_as_spent", e.to_string());
1709 retained_mark_faulted |= admittance
1710 .coins_to_retain
1711 .contains(&v.previous_coins[prev_idx]);
1712 }
1713
1714 // The input index within tx.inputs that spends this previous output.
1715 let spending_input_idx = v.previous_coins[prev_idx] as usize;
1716
1717 // Notify all lookup services about the spent output
1718 for ls in self.lookup_services.values() {
1719 let payload = match ls.spend_notification_mode() {
1720 SpendNotificationMode::None => OutputSpent::None {
1721 txid: prev_output.txid.clone(),
1722 output_index: prev_output.output_index,
1723 topic: topic.clone(),
1724 },
1725 SpendNotificationMode::Txid => OutputSpent::Txid {
1726 txid: prev_output.txid.clone(),
1727 output_index: prev_output.output_index,
1728 topic: topic.clone(),
1729 spending_txid: txid.clone(),
1730 },
1731 SpendNotificationMode::Script => {
1732 // Extract unlocking script and sequence from the spending input
1733 let input = &tx.inputs[spending_input_idx];
1734 let unlocking_script = input
1735 .unlocking_script
1736 .as_ref()
1737 .map(bsv_rs::UnlockingScript::to_binary)
1738 .unwrap_or_default();
1739 OutputSpent::Script {
1740 txid: prev_output.txid.clone(),
1741 output_index: prev_output.output_index,
1742 topic: topic.clone(),
1743 spending_txid: txid.clone(),
1744 input_index: spending_input_idx as u32,
1745 unlocking_script,
1746 sequence_number: input.sequence,
1747 off_chain_values: tagged_beef.off_chain_values.clone(),
1748 }
1749 }
1750 SpendNotificationMode::WholeTx => OutputSpent::WholeTx {
1751 txid: prev_output.txid.clone(),
1752 output_index: prev_output.output_index,
1753 topic: topic.clone(),
1754 spending_atomic_beef: subject_named_beef.clone(),
1755 off_chain_values: tagged_beef.off_chain_values.clone(),
1756 },
1757 };
1758 if let Err(e) = hooked(bound, ls.output_spent(&payload)).await {
1759 error!("Error notifying lookup service of spent output: {e}");
1760 report.fault(topic, "lookup_service.output_spent", e);
1761 }
1762 }
1763 }
1764 if retained_mark_faulted {
1765 self.hold_unapplied(
1766 &txid,
1767 topic,
1768 "the spent mark of a retained coin faulted, nothing inserted",
1769 );
1770 continue;
1771 }
1772
1773 // ── Insert admitted outputs ──
1774 // The inserts stop at the first one that faults: what follows is
1775 // the undo, not the rest of the transaction.
1776 let mut new_utxos: Vec<Outpoint> = Vec::new();
1777 let mut attempted: Vec<u32> = Vec::new();
1778 let mut insert_faulted = false;
1779
1780 for &output_index in &admittance.outputs_to_admit {
1781 let (script_bytes, sats) =
1782 if let Some(tx_output) = tx.outputs.get(output_index as usize) {
1783 (
1784 tx_output.locking_script.to_binary(),
1785 tx_output.get_satoshis(),
1786 )
1787 } else {
1788 (Vec::new(), 0)
1789 };
1790
1791 let output = Output {
1792 txid: txid.clone(),
1793 output_index,
1794 output_script: script_bytes,
1795 satoshis: sats,
1796 topic: topic.clone(),
1797 spent: false,
1798 outputs_consumed: outputs_consumed.clone(),
1799 consumed_by: Vec::new(),
1800 beef: Some(tagged_beef.beef.clone()),
1801 block_height: None,
1802 score: Some(current_timestamp_ms()),
1803 };
1804
1805 attempted.push(output_index);
1806 if let Err(e) = stored(bound, self.storage.insert_output(&output)).await {
1807 error!("Error inserting output for topic {topic}: {e}");
1808 report.fault(topic, "insert_output", e.to_string());
1809 insert_faulted = true;
1810 break;
1811 }
1812
1813 new_utxos.push(Outpoint::new(&txid, output_index));
1814
1815 // Notify lookup services
1816 for ls in self.lookup_services.values() {
1817 let (ls_script, ls_sats) =
1818 if let Some(tx_out) = tx.outputs.get(output_index as usize) {
1819 (tx_out.locking_script.to_binary(), tx_out.get_satoshis())
1820 } else {
1821 (Vec::new(), 0)
1822 };
1823
1824 let payload = match ls.admission_mode() {
1825 AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
1826 txid: txid.clone(),
1827 output_index,
1828 topic: topic.clone(),
1829 satoshis: ls_sats,
1830 locking_script: ls_script,
1831 off_chain_values: tagged_beef.off_chain_values.clone(),
1832 },
1833 AdmissionMode::WholeTx => OutputAdmittedByTopic::WholeTx {
1834 atomic_beef: subject_named_beef.clone(),
1835 output_index,
1836 topic: topic.clone(),
1837 off_chain_values: tagged_beef.off_chain_values.clone(),
1838 },
1839 };
1840
1841 if let Err(e) = hooked(bound, ls.output_admitted_by_topic(&payload)).await {
1842 error!("Error notifying lookup service: {e}");
1843 report.fault(topic, "lookup_service.output_admitted_by_topic", e);
1844 }
1845 }
1846 }
1847
1848 // An insert faulted: the transaction did not land, so NOTHING of
1849 // it stays (the lens fold of 2026-10-07, F1). The previous coins
1850 // are kept (#559: with the delete first, one faulted insert left
1851 // a non-retaining head chain with NO head) and the outputs that
1852 // were inserted are taken out again, the faulted one included (a
1853 // backend's insert can fault after its row landed). Left in,
1854 // they could be spent and deleted by a successor before the
1855 // replay, and the replay, which still finds the previous coin,
1856 // would insert them a second time: a spent head back among the
1857 // UTXOs for good.
1858 if insert_faulted {
1859 if let Some(bound) = bound {
1860 bound.renew();
1861 }
1862 self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
1863 .await;
1864 self.hold_unapplied(
1865 &txid,
1866 topic,
1867 &format!(
1868 "an insert faulted: {} previous coin(s) kept, nothing of it held",
1869 stale_coins.len()
1870 ),
1871 );
1872 continue;
1873 }
1874
1875 // ── Delete stale outputs recursively ──
1876 // AFTER the inserts, and once every one of them landed (bsv-low
1877 // #559 and the lens fold of 2026-10-07, F1). The reference
1878 // deletes first (`applyTopicStorageMutation`: `removeStaleOutputs`,
1879 // then `admitOutput`), catches a throw per topic and never
1880 // replays; here a fault is survived and replayed, so the order
1881 // is ours, an addition. The delete does NOT wait for a clean
1882 // report (c9921ee did, and one faulted mark or notification
1883 // after the insert then left the stale coin beside the new
1884 // output for good: two unspent heads): a fault of the mark, of
1885 // a notification, deletes as the reference does.
1886 //
1887 // Before a stale coin is deleted its transaction is recorded as
1888 // applied in the topic (the delta fold of 2026-10-07, H2; an
1889 // addition, one idempotent write per deleted coin). A held coin
1890 // proves the inserts of its transaction landed, but that
1891 // transaction may have no applied row (a fault after its
1892 // inserts withheld it, or its insert landed after its timeout),
1893 // and then its replay is judged again: a manager that admits it
1894 // with NO previous coin (the opener of a chain) had it inserted
1895 // a second time, unspent, beside the output that spent it, for
1896 // good. With the row its replay is a dupe. A record that faults
1897 // leaves its coin undeleted: that is a delete fault, below.
1898 let due_before_the_delete = bound.is_some_and(CallBound::is_due);
1899 let mut delete_faulted = false;
1900 let mut delete_started = false;
1901 for stale in &stale_coins {
1902 let spent_coin_tx = AppliedTransaction {
1903 txid: stale.txid.clone(),
1904 topic: topic.clone(),
1905 };
1906 if let Err(e) = stored(
1907 bound,
1908 self.storage.insert_applied_transaction(&spent_coin_tx),
1909 )
1910 .await
1911 {
1912 error!(
1913 "Error recording {} as applied before its coin is deleted from topic {topic}: {e}",
1914 stale.txid
1915 );
1916 report.fault(topic, "record_spent_coin_applied", e.to_string());
1917 delete_faulted = true;
1918 continue;
1919 }
1920 self.not_landed
1921 .borrow_mut()
1922 .remove(&(stale.txid.clone(), topic.clone()));
1923 match stored(
1924 bound,
1925 self.storage.find_output(
1926 &stale.txid,
1927 stale.output_index,
1928 Some(topic),
1929 None,
1930 false,
1931 ),
1932 )
1933 .await
1934 {
1935 Ok(Some(stale_output)) => {
1936 delete_started = true;
1937 if let Err(e) = self.delete_utxo_deep(&stale_output, bound).await {
1938 error!("Error deleting stale output for topic {topic}: {e}");
1939 report.fault(topic, "delete_utxo_deep", e.to_string());
1940 delete_faulted = true;
1941 }
1942 }
1943 Ok(None) => {}
1944 Err(e) => {
1945 error!("Error reading stale output for topic {topic}: {e}");
1946 report.fault(topic, "find_output", e.to_string());
1947 delete_faulted = true;
1948 }
1949 }
1950 }
1951
1952 // A fault here, and what it leaves. The store is put back as it
1953 // was found (the inserts undone, the replay does the whole
1954 // transaction) only when NO delete was started: the fault is of
1955 // H2's record or of the read before the delete, the call
1956 // answered, and EVERY stale coin is read back still held.
1957 //
1958 // Once a delete was STARTED nothing is undone, whether it
1959 // answered or not (the delta fold of 2026-10-07, H1, for a call
1960 // that did not answer; the second delta fold, M1, for one that
1961 // answered an error). A statement can land after its caller was
1962 // told it failed, as after its caller stopped waiting: the coin
1963 // is read back held now and is deleted a moment later, and the
1964 // undo then left the chain with no head, for good, for a manager
1965 // that needs the previous coin. So the outputs stay, the stale
1966 // coin stays (marked spent) until its delete lands or is done
1967 // again, and there is no applied row. Since H2 every order from
1968 // there converges. The replay first, the delete never landed:
1969 // the coin is found, the manager admits again, the inserts are
1970 // no-ops, the delete is done and the row written. The replay
1971 // first, the delete landed late: no previous coin, so a manager
1972 // that needs one admits nothing and the transaction is recorded
1973 // with its outputs held. A successor first: it recorded this
1974 // transaction as applied (H2) and the replay is a dupe; the
1975 // stale coin's spent row is then left behind, no UTXO.
1976 //
1977 // A call that did not ANSWER before any delete (the record, the
1978 // read) undoes nothing either: it is not known what it did. A
1979 // deadline that was already due BEFORE the first of these calls
1980 // started nothing here, so the coins are certainly held and the
1981 // store is put back.
1982 if delete_faulted {
1983 let unanswered = !due_before_the_delete && bound.is_some_and(CallBound::is_due);
1984 if let Some(bound) = bound {
1985 bound.renew();
1986 }
1987 let mut all_held = !unanswered && !delete_started;
1988 for stale in &stale_coins {
1989 if !all_held {
1990 break;
1991 }
1992 let read = stored(
1993 bound,
1994 self.storage.find_output(
1995 &stale.txid,
1996 stale.output_index,
1997 Some(topic),
1998 None,
1999 false,
2000 ),
2001 )
2002 .await;
2003 if !matches!(read, Ok(Some(_))) {
2004 all_held = false;
2005 break;
2006 }
2007 }
2008 if all_held {
2009 self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
2010 .await;
2011 }
2012 self.hold_unapplied(
2013 &txid,
2014 topic,
2015 if all_held {
2016 "no delete of a stale coin was started: the coins are held, its outputs taken out"
2017 } else if unanswered {
2018 "the delete of a stale coin did not answer: nothing undone, its outputs stay"
2019 } else if delete_started {
2020 "the delete of a stale coin faulted: nothing undone, its outputs stay"
2021 } else {
2022 "no delete was started and a stale coin was not read back held: nothing undone, its outputs stay"
2023 },
2024 );
2025 continue;
2026 }
2027
2028 // ── Update consumedBy on retained previous outputs ──
2029 for consumed_outpoint in &outputs_consumed {
2030 let consumed_output = match stored(
2031 bound,
2032 self.storage.find_output(
2033 &consumed_outpoint.txid,
2034 consumed_outpoint.output_index,
2035 Some(topic),
2036 None,
2037 false,
2038 ),
2039 )
2040 .await
2041 {
2042 Ok(Some(o)) => o,
2043 Ok(None) => continue,
2044 Err(e) => {
2045 error!("Error reading consumed output for topic {topic}: {e}");
2046 report.fault(topic, "find_output", e.to_string());
2047 continue;
2048 }
2049 };
2050 {
2051 let mut new_consumed_by = consumed_output.consumed_by.clone();
2052 for new_utxo in &new_utxos {
2053 if !new_consumed_by.iter().any(|c| {
2054 c.txid == new_utxo.txid && c.output_index == new_utxo.output_index
2055 }) {
2056 new_consumed_by.push(new_utxo.clone());
2057 }
2058 }
2059 if let Err(e) = stored(
2060 bound,
2061 self.storage.update_consumed_by(
2062 &consumed_outpoint.txid,
2063 consumed_outpoint.output_index,
2064 topic,
2065 &new_consumed_by,
2066 ),
2067 )
2068 .await
2069 {
2070 error!("Error updating consumedBy: {e}");
2071 report.fault(topic, "update_consumed_by", e.to_string());
2072 }
2073 }
2074 }
2075
2076 // Record applied transaction — ONLY when every write above
2077 // landed. A faulted topic stays unrecorded so the Phase-1 dedup
2078 // cannot turn its replay into a no-op (S2).
2079 if report.faults.len() > faults_before {
2080 self.hold_unapplied(
2081 &txid,
2082 topic,
2083 &format!(
2084 "{} mutation fault(s); a replay of these bytes re-applies",
2085 report.faults.len() - faults_before
2086 ),
2087 );
2088 continue;
2089 }
2090 let tx_record = AppliedTransaction {
2091 txid: txid.clone(),
2092 topic: topic.clone(),
2093 };
2094 match stored(bound, self.storage.insert_applied_transaction(&tx_record)).await {
2095 Ok(()) => {
2096 report.applied_topics.push(topic.clone());
2097 self.not_landed
2098 .borrow_mut()
2099 .remove(&(txid.clone(), topic.clone()));
2100 }
2101 Err(e) => {
2102 error!("Error inserting applied transaction for topic {topic}: {e}");
2103 report.fault(topic, "insert_applied_transaction", e.to_string());
2104 self.not_landed
2105 .borrow_mut()
2106 .insert((txid.clone(), topic.clone()));
2107 }
2108 }
2109 }
2110
2111 Ok((steak, report, blocked))
2112 }
2113
2114 /// The successor rule (bsv-low #559; lane E1D, bsv-low #575; and its
2115 /// lens fold of 2026-10-08), asked of every topic of a submit that found
2116 /// NO previous coin while the transaction spends something, before
2117 /// anything of the submit is written. Returns the topics that must wait,
2118 /// each with why: those are reported as a fault (`predecessor_not_landed`)
2119 /// and recorded nowhere, "not now". An addition to the reference, which
2120 /// records every non-failed topic.
2121 ///
2122 /// Such a topic was judged without the coin a predecessor has yet to
2123 /// leave. Recorded as applied, its replay was a dupe for good: a
2124 /// transaction that admits nothing ended the chain at the predecessor
2125 /// (#559), and one the manager admits WITHOUT its coin (the pf head
2126 /// manager over its own head state, E1D) was inserted, and the
2127 /// predecessor, landing later, stood unspent beside the tip.
2128 ///
2129 /// "Has not landed" is known two ways: this engine saw it fault
2130 /// (`not_landed`, one invocation, every door), or the store says so
2131 /// ([`Engine::unlanded_predecessor`], any invocation, never at a GASP
2132 /// finalize submit: its graph passed the anchor check, every parent
2133 /// inside the graph was submitted just before it and the sequence stops
2134 /// at the first that does not land; the second delta fold of 2026-10-07,
2135 /// M2). For a successor that admits something, both look only at the
2136 /// inputs the manager NAMES as its history (`identify_needed_inputs`
2137 /// over the subject-named BEEF; the lens fold, L1): a manager that
2138 /// names nothing (all 16 of this workspace) asks nothing there, as in
2139 /// the reference. A successor that admits NOTHING is asked of every
2140 /// input whatever its manager names (D17 (a)), so under such a manager
2141 /// it still waits for, or lands first, a carried body that spends a
2142 /// coin the topic holds: there the store differs from the reference's
2143 /// (the E1D delta fold, L3).
2144 ///
2145 /// The door lands a carried predecessor FIRST (the lens fold, M1; the
2146 /// GASP walk's rule, at the door): when the question names an unlanded
2147 /// predecessor whose body the BEEF carries, that body is submitted on
2148 /// its own to this topic ([`Engine::land_carried`]), under the submit's
2149 /// read allowance, and once it lands the topic is judged again
2150 /// ([`Engine::validate_topic`]) and asked again. The re-judgement's
2151 /// reads (the applied row, one coin per input) are charged to that
2152 /// allowance BEFORE the landing (the E1D delta fold, M1): a landing whose
2153 /// re-judgement would not fit is not made, and the topic waits. So one
2154 /// submit reads at most its own validation and writes plus
2155 /// [`PREDECESSOR_READS_PER_SUBMIT`], landings (on their clean path) and
2156 /// re-judgements included; a faulted landing's read-backs and undo, and
2157 /// a deep delete's walk of retained history, are not charged.
2158 /// Nothing is landed into a topic whose manager or a lookup
2159 /// service reads off-chain values (the delta fold, L1: the landing has
2160 /// none to give, and the predecessor's own submit would be a dupe): the
2161 /// topic waits for that submit. The first predecessor that does not land
2162 /// ends it: the topic waits. A predecessor whose body the BEEF does not
2163 /// carry is never landed here: its successor converges only when it
2164 /// lands (its own replay, a GASP peer, a resubmit).
2165 #[allow(clippy::too_many_arguments)]
2166 async fn successors_waiting(
2167 &self,
2168 tx: &Transaction,
2169 txid: &str,
2170 subject_named_beef: &[u8],
2171 validations: &mut [TopicValidation],
2172 steak: &mut Steak,
2173 off_chain_values: Option<&[u8]>,
2174 mode: SubmitMode,
2175 bound: Option<&CallBound>,
2176 finalize: bool,
2177 lands: bool,
2178 blocked: &mut HashMap<String, String>,
2179 question_reads: &mut usize,
2180 landed: &mut Vec<(String, String)>,
2181 landing: Landing,
2182 landed_breaches: &mut Vec<WalkCouldNotRun>,
2183 ) -> HashMap<String, String> {
2184 let mut waits = HashMap::new();
2185 let spent: Vec<(String, u32)> = tx
2186 .inputs
2187 .iter()
2188 .filter_map(|input| {
2189 let source = input.get_source_txid().ok()?;
2190 (!source.is_empty()).then_some((source, input.source_output_index))
2191 })
2192 .collect();
2193 if spent.is_empty() {
2194 return waits;
2195 }
2196 let found_no_coin = |v: &TopicValidation| {
2197 !v.is_dupe && !v.failed && v.read_fault.is_none() && v.previous_outputs.is_empty()
2198 };
2199 for v in validations.iter_mut() {
2200 if !found_no_coin(v) {
2201 continue;
2202 }
2203 let Some(manager) = self.managers.get(&v.topic) else {
2204 continue;
2205 };
2206 let topic = v.topic.clone();
2207 // The manager's word on the subject's history, asked at most
2208 // once per topic: up front for a successor that admits, by the
2209 // question when it needs it for one that does not.
2210 let mut subject_names: Option<HashSet<String>> = None;
2211 loop {
2212 let admits = !v.admittance.outputs_to_admit.is_empty();
2213 if admits && subject_names.is_none() {
2214 subject_names = Some(
2215 self.named_history(manager.as_ref(), subject_named_beef, bound)
2216 .await,
2217 );
2218 }
2219 let remembered = spent.iter().find(|(source, vout)| {
2220 (!admits
2221 || subject_names
2222 .as_ref()
2223 .is_some_and(|named| named.contains(&format!("{source}.{vout}"))))
2224 && self
2225 .not_landed
2226 .borrow()
2227 .contains(&(source.clone(), topic.clone()))
2228 });
2229 if let Some((predecessor, _)) = remembered {
2230 waits.insert(topic.clone(), format!("{predecessor} has not landed"));
2231 break;
2232 }
2233 if finalize {
2234 break;
2235 }
2236 let Some(not_now) = self
2237 .unlanded_predecessor(
2238 tx,
2239 txid,
2240 subject_named_beef,
2241 &topic,
2242 mode,
2243 admits,
2244 &mut subject_names,
2245 question_reads,
2246 bound,
2247 )
2248 .await
2249 else {
2250 break;
2251 };
2252 let Some(carried) = not_now.carried else {
2253 waits.insert(topic.clone(), not_now.why);
2254 break;
2255 };
2256 if !lands {
2257 blocked.insert(topic.clone(), carried);
2258 waits.insert(topic.clone(), not_now.why);
2259 break;
2260 }
2261 if !self.lands_without_values(&topic) {
2262 waits.insert(
2263 topic.clone(),
2264 format!(
2265 "{}; not landed first from the BEEF: the topic's manager or a lookup service reads off-chain values, and only the predecessor's own submit carries them",
2266 not_now.why
2267 ),
2268 );
2269 break;
2270 }
2271 // The re-judgement after the landing, charged before it.
2272 let rejudge = 1 + spent.len();
2273 if *question_reads + rejudge > PREDECESSOR_READS_PER_SUBMIT {
2274 waits.insert(
2275 topic.clone(),
2276 format!(
2277 "{}; its landing and the re-judgement after it do not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads",
2278 not_now.why
2279 ),
2280 );
2281 break;
2282 }
2283 *question_reads += rejudge;
2284 match self
2285 .land_carried(
2286 subject_named_beef,
2287 txid,
2288 &carried,
2289 &topic,
2290 mode,
2291 bound,
2292 question_reads,
2293 landed,
2294 landing,
2295 landed_breaches,
2296 )
2297 .await
2298 {
2299 Ok(()) => {
2300 *v = self
2301 .validate_topic(
2302 tx,
2303 txid,
2304 &topic,
2305 off_chain_values,
2306 mode,
2307 &TopicAdmittanceContext::default(),
2308 bound,
2309 )
2310 .await;
2311 steak.insert(topic.clone(), v.admittance.clone());
2312 if !found_no_coin(v) {
2313 break;
2314 }
2315 }
2316 Err(did_not_land) => {
2317 // No re-judgement follows: its reads were not made.
2318 *question_reads -= rejudge;
2319 waits.insert(
2320 topic.clone(),
2321 format!(
2322 "{}; submitted first from the BEEF, it did not land ({did_not_land})",
2323 not_now.why
2324 ),
2325 );
2326 break;
2327 }
2328 }
2329 }
2330 // A subject that already HOLDS an output in the topic landed
2331 // there before (the leftover of its own earlier submit whose
2332 // delete was started and faulted, D17 M1: its outputs held, no
2333 // applied row), and that admission was judged with its coin.
2334 // Its replay finishes it and does not wait (the E1D lens fold,
2335 // L4: a proven head spend that names a decoy no peer serves was
2336 // "not now" on every replay, three and a dead letter). One read,
2337 // only on the way to "not now", inside the allowance.
2338 if !finalize
2339 && waits.contains_key(&topic)
2340 && *question_reads < PREDECESSOR_READS_PER_SUBMIT
2341 {
2342 *question_reads += 1;
2343 if let Ok(outputs) = stored(
2344 bound,
2345 self.storage.find_outputs_for_transaction(txid, false),
2346 )
2347 .await
2348 {
2349 if outputs.iter().any(|o| o.topic == topic) {
2350 info!("topic {topic}: {txid} holds its outputs already; its replay finishes it");
2351 waits.remove(&topic);
2352 blocked.remove(&topic);
2353 }
2354 }
2355 }
2356 }
2357 waits
2358 }
2359
2360 /// Land an unlanded predecessor whose body the successor's BEEF carries
2361 /// (lane E1D's lens fold, M1): submit it on its own to `topic`, its
2362 /// atomic BEEF out of the successor's, no off-chain values (the caller
2363 /// lands nothing into a topic that reads them; the delta fold, L1),
2364 /// never a broadcast (`current-tx` is submitted as `historical-tx`: the
2365 /// successor's BEEF carries it to the network).
2366 ///
2367 /// Each body is WALKED once in this submit (the delta fold, L4): the
2368 /// successor's own SPV walk (`subject`, the same mode, tracker and
2369 /// switch) already passed over every body it reaches without crossing a
2370 /// proven one, so those are not walked again; a body reached only
2371 /// through a proven one (which the successor's walk does not descend)
2372 /// gets its own walk, trusting what this submit already walked, and so
2373 /// does nothing twice. A walk that refuses a body ends the landing.
2374 ///
2375 /// A predecessor whose own submit waits on a carried predecessor of ITS
2376 /// own goes on a stack under it: ancestors first, the walk's rule at
2377 /// the door. Its manager was asked only a DRY RUN then and nothing was
2378 /// written; once the one under it landed it is submitted again and
2379 /// admitted for real, once ([`Engine::submit_counted`]). Each submit's
2380 /// reads on its clean path (its applied row, one coin per input, and
2381 /// its writes' one read of each coin it spends) are charged to the
2382 /// submit's allowance before it starts, and its predecessor
2383 /// question shares that allowance, so the landings of one submit stop
2384 /// at [`PREDECESSOR_READS_PER_SUBMIT`] reads, however deep the chain.
2385 /// Each body that lands is pushed on `landed` (the report's
2386 /// `landed_predecessors`, for the caller's admission guard; the delta
2387 /// fold, L2). Each body is asked of the caller's landing guard first,
2388 /// once ([`Engine::set_landing_guard`]; the delta-2 fold, L2): a refusal
2389 /// ends the landing before that body is submitted.
2390 /// `Ok` once `predecessor` LANDED in `topic` (recorded there,
2391 /// now or before); otherwise why not, at the first that did not.
2392 #[allow(clippy::too_many_arguments)]
2393 async fn land_carried(
2394 &self,
2395 beef_bytes: &[u8],
2396 subject: &str,
2397 predecessor: &str,
2398 topic: &str,
2399 mode: SubmitMode,
2400 bound: Option<&CallBound>,
2401 question_reads: &mut usize,
2402 landed: &mut Vec<(String, String)>,
2403 landing: Landing,
2404 landed_breaches: &mut Vec<WalkCouldNotRun>,
2405 ) -> Result<(), String> {
2406 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
2407 .map_err(|e| format!("the BEEF could not be read again ({e})"))?;
2408 let mode = match mode {
2409 SubmitMode::CurrentTx => SubmitMode::HistoricalTx,
2410 other => other,
2411 };
2412 let walks = mode != SubmitMode::HistoricalTxNoSpv;
2413 // The trust set is what a walk that RAN covered (the E592 lens fold,
2414 // M1): a subject whose walk breached covered nothing, so each carried
2415 // predecessor is walked by its own submit (and its own breach is
2416 // reported, `landed_breaches`), never trusted by a breached walk.
2417 let mut walked: HashSet<String> = if walks && landing.subject_walk_ran {
2418 walk_cover(&beef, subject)
2419 } else {
2420 HashSet::new()
2421 };
2422 let mut stack = vec![predecessor.to_string()];
2423 let mut guarded: HashSet<String> = HashSet::new();
2424 while let Some(next) = stack.last().cloned() {
2425 let body = beef
2426 .find_atomic_transaction(&next)
2427 .ok_or_else(|| format!("{next}: its body is not in the BEEF"))?;
2428 // The caller's admission predicate, before anything of this body
2429 // is submitted (the delta-2 fold, L2).
2430 if let Some(guard) = &self.landing_guard {
2431 if guarded.insert(next.clone()) {
2432 bounded(bound, guard(&next, topic))
2433 .await
2434 .and_then(|answer| answer)
2435 .map_err(|why| format!("{next}: refused by the landing guard ({why})"))?;
2436 }
2437 }
2438 // Its validation (the applied row, one coin per input) and its
2439 // writes' one read of each coin it spends.
2440 let cost = 1 + 2 * body.inputs.len();
2441 if *question_reads + cost > PREDECESSOR_READS_PER_SUBMIT {
2442 return Err(format!(
2443 "{next}: its submit does not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2444 ));
2445 }
2446 *question_reads += cost;
2447 let atomic = body
2448 .to_atomic_beef(true)
2449 .map_err(|e| format!("{next}: its BEEF could not be built ({e})"))?;
2450 let tagged = TaggedBEEF::new(atomic, vec![topic.to_string()]);
2451 let (_, report, blocked) = match self
2452 .submit_counted(
2453 &tagged,
2454 mode,
2455 bound,
2456 false,
2457 Some(&walked),
2458 question_reads,
2459 landing.policy,
2460 )
2461 .await
2462 {
2463 Ok(counted) => counted,
2464 // Its walk could not run and nothing stands in for it: it does
2465 // not land now, and the successor waits (counted, L3).
2466 Err(EngineError::WalkCouldNotRun(stop)) => {
2467 let why = format!("{next}: {}", EngineError::WalkCouldNotRun(stop.clone()));
2468 landed_breaches.push(stop);
2469 return Err(why);
2470 }
2471 Err(e) => return Err(format!("{next}: {e}")),
2472 };
2473 // Its walk passed, so did that of every body it reaches; a walk
2474 // that went on past a breach (`NetworkAccepted`) passed nothing.
2475 if let Some(stop) = &report.walk_could_not_run {
2476 landed_breaches.push(stop.clone());
2477 } else if walks && !walked.contains(&next) {
2478 walked.extend(walk_cover(&beef, &next));
2479 }
2480 let landed_now = report.applied_topics.iter().any(|t| t == topic);
2481 if report.is_durable()
2482 && (landed_now || report.deduped_topics.iter().any(|t| t == topic))
2483 {
2484 info!("topic {topic}: {next} landed first from its successor's BEEF");
2485 if landed_now {
2486 landed.push((next.clone(), topic.to_string()));
2487 }
2488 stack.pop();
2489 continue;
2490 }
2491 match blocked.get(topic) {
2492 Some(under) if !stack.contains(under) => stack.push(under.clone()),
2493 _ if report.is_durable() => return Err(format!("{next}: the manager failed it")),
2494 _ => return Err(format!("{next}: {}", report.summary())),
2495 }
2496 }
2497 Ok(())
2498 }
2499
2500 /// A topic of a submit that is NOT recorded as applied: logged, and
2501 /// remembered for the successor rule (`not_landed`).
2502 fn hold_unapplied(&self, txid: &str, topic: &str, why: &str) {
2503 warn!("topic {topic}: {txid}: {why}; applied_transactions NOT recorded (bsv-low #559)");
2504 self.not_landed
2505 .borrow_mut()
2506 .insert((txid.to_string(), topic.to_string()));
2507 }
2508
2509 /// Take out again the outputs a faulted submit inserted (bsv-low #559,
2510 /// the lens fold of 2026-10-07, F1), so that nothing of a transaction
2511 /// that did not land is held. Only a row nobody has touched since is
2512 /// deleted: unspent and consumed by nothing. A plain delete, not the
2513 /// deep one: the row's retained inputs were not pointed at it yet. A
2514 /// fault here is reported (`undo_insert_output`) and the row stays: the
2515 /// limit of an undo made of separate calls.
2516 async fn undo_inserts(
2517 &self,
2518 txid: &str,
2519 topic: &str,
2520 output_indices: &[u32],
2521 bound: Option<&CallBound>,
2522 report: &mut MutationReport,
2523 ) {
2524 for &output_index in output_indices {
2525 let row = stored(
2526 bound,
2527 self.storage
2528 .find_output(txid, output_index, Some(topic), None, false),
2529 )
2530 .await;
2531 let undone = match row {
2532 Ok(Some(row)) if !row.spent && row.consumed_by.is_empty() => {
2533 stored(bound, self.storage.delete_output(txid, output_index, topic)).await
2534 }
2535 Ok(_) => Ok(()),
2536 Err(e) => Err(e),
2537 };
2538 if let Err(e) = undone {
2539 error!("Error taking {txid}.{output_index} out of topic {topic} again: {e}");
2540 report.fault(topic, "undo_insert_output", e.to_string());
2541 }
2542 }
2543 }
2544
2545 /// The store's own answer to "has a predecessor of `tx` not landed?"
2546 /// (bsv-low #559, the lens fold of 2026-10-07, F3; lane E1D, bsv-low
2547 /// #575, and its lens fold of 2026-10-08), for a transaction that found
2548 /// no previous coin. The engine's memory of a faulted submit
2549 /// (`not_landed`) dies with the invocation; this does not. A transaction
2550 /// HAS LANDED in `topic` when it has an applied row there or an output
2551 /// held there; anything else is not known to have landed, and it is a
2552 /// PREDECESSOR of `tx` when one of these says so:
2553 ///
2554 /// - its body is in the BEEF and it spends a coin the topic HOLDS (or,
2555 /// one step up, a coin of another such transaction): a spend of this
2556 /// topic the store has not taken yet (#559);
2557 /// - its body is in the BEEF, it spends no coin the topic holds, the
2558 /// manager NAMES the outpoint the walk spends from it as overlay
2559 /// history (`identify_needed_inputs` over the BEEF of the transaction
2560 /// that spends it, `tx` or a body the walk reached), and the manager,
2561 /// asked of that body in a dry run with no coins
2562 /// (`TopicAdmittanceContext::DRY_RUN`, the submit's mode, no off-chain
2563 /// values), would admit that named output: an OPENER that has not
2564 /// landed (E1D, the cure D17 named). Only a NAMED output is dry-run
2565 /// (the lens fold, H1): with every unheld body dry-run, a manager that
2566 /// admits on output shape (all 16 of this workspace) made a spend of a
2567 /// shape-admissible output this node never held (a revocation of an
2568 /// ad it never saw; a stranger's own few-sat SHIP output) "not now" on
2569 /// every presentation, three replays and a dead letter each, where the
2570 /// reference records it. A manager `Err` in the dry run is "not now"
2571 /// (the manager's contract at the anchor replay, D15), except its
2572 /// typed refusal `NoAdmissibleOutputs`, which is "not admitted";
2573 /// - its body is NOT in the BEEF (a PROVEN successor carries none) and
2574 /// the manager names the outpoint `tx` spends from it as overlay
2575 /// history (D13's word; an `Err` there names nothing, as in the walk).
2576 ///
2577 /// The manager is asked over `beef_bytes`, the SUBJECT-NAMED BEEF of the
2578 /// submit (BRC-95 atomic, the body the lookup services get; the lens
2579 /// fold, M2): over the submitted bytes a manager that parses
2580 /// `from_beef(_, None)` took the wire-LAST transaction of a non-atomic
2581 /// BEEF, named an ancestor's inputs, and the question asked nothing.
2582 ///
2583 /// `admits` says which inputs of `tx` start the walk. A transaction that
2584 /// admits nothing (D17's class) starts from every input. One that admits
2585 /// WITHOUT its coin (E1D) starts only from the inputs the manager names:
2586 /// a manager that admits a successor with no coin has said the coin does
2587 /// not decide the admission, so only its own word makes an input a
2588 /// predecessor. Every manager of this workspace names nothing: an
2589 /// admitting successor is then asked nothing, as in the reference, and
2590 /// so is a spend of an unheld output nobody names (the lens fold, H1);
2591 /// but a successor that admits nothing still finds case (a), a carried
2592 /// body that spends a coin the topic holds, and waits for it or has it
2593 /// landed first, where the reference records the successor and never
2594 /// admits that body (the E1D delta fold, L3). A named input whose body
2595 /// the BEEF carries is still walked and dry-run, so a named decoy the
2596 /// manager would not admit is no predecessor.
2597 ///
2598 /// The answer carries the predecessor when its body is in the BEEF (the
2599 /// first two cases): the door lands it first ([`Engine::successors_waiting`]).
2600 ///
2601 /// The limits, stated. It cannot tell a faulted predecessor from one
2602 /// nobody submitted yet: a successor is "not now" in both until the
2603 /// predecessor lands (the door lands a carried one itself). A named
2604 /// input whose body is absent and which never lands (a decoy no one
2605 /// submits, beside a real predecessor that landed and holds no coin)
2606 /// keeps its successor "not now" at the door; the GASP walk prunes such
2607 /// an input instead (D14). Deeper than the successor's own inputs, an
2608 /// absent body ends the walk as before.
2609 ///
2610 /// "Landed" needs a clean answer (the delta fold of 2026-10-07, M1 and
2611 /// M2): a read that faults, or the question running out of its reads
2612 /// (the 16 of a topic or the 256 of the submit), answers "not now" too,
2613 /// naming the transaction it could not settle. Recording the successor
2614 /// there made its replay a dupe and stopped the chain behind it for
2615 /// good; a retried no-op is the lesser cost.
2616 ///
2617 /// What the bound counts (the second delta fold of 2026-10-07, M2): the
2618 /// reads spent on a body that is NEITHER proven in the BEEF NOR found
2619 /// landed, [`PREDECESSOR_READS`] of them per topic. A body the BEEF
2620 /// proves, and one whose applied row or held output answers "landed", is
2621 /// read and costs nothing against that bound: counted, they made a
2622 /// transaction that admits nothing over 17 landed parents, over six
2623 /// proven parents the topic never saw, or over one proven parent with 15
2624 /// inputs "not now" on every submit, with no unlanded predecessor
2625 /// anywhere. A landed body still needs ROOM under it: each read of an
2626 /// unproven body is tested before it is made, so with 15 counted reads
2627 /// spent one that landed by a held output (no applied row) is refused at
2628 /// its second read, and with 16 spent any unproven body at its first.
2629 ///
2630 /// "Proven" is the BEEF's word here: a body that carries a merkle path
2631 /// (a bump index) is counted as proven for the read bound ONLY. No proof
2632 /// is checked at this question; the SPV walk of the submit and the GASP
2633 /// anchor check are where proofs are checked, and under
2634 /// `historical-tx-no-spv` or the scripts-only walk nothing checked this
2635 /// one, so the 16 are the caller's to switch off. Nothing is recorded on
2636 /// that word (a proven body is walked like any other), and the allowance
2637 /// below does not ask it.
2638 ///
2639 /// The hard allowance (the third delta fold of 2026-10-07, M1):
2640 /// `question_reads`, owned by the submit, counts EVERY read of the
2641 /// question, those of proven and landed bodies included, over every
2642 /// topic of the submit, and stops it at
2643 /// [`PREDECESSOR_READS_PER_SUBMIT`]; the 16 stay inside it. A later
2644 /// topic's question starts from what the earlier ones left.
2645 ///
2646 /// The costs, stated. A transaction that admits nothing, found no coin
2647 /// and carries more UNPROVEN, UNLANDED bodies than 16 reads settle (five
2648 /// single-input ancestors, fewer with more inputs), or a BEEF whose
2649 /// question needs more than 256 reads over the submit's topics (a
2650 /// landed body costs one read by its applied row or two by a held
2651 /// output, a proven, unlanded one two and one per input), is never
2652 /// recorded, where the reference records it: each submit of it is a
2653 /// fault and a replay. The manager CPU it adds: one names call per
2654 /// spender a dry run needs (the subject's, and one per walked body with
2655 /// an unheld source), and at most one dry run per body the reads
2656 /// reached; each candidate is built out of the BEEF parsed once (the
2657 /// lens fold, L2).
2658 ///
2659 /// Returns why the submit must wait, for the report.
2660 #[allow(clippy::too_many_arguments)]
2661 async fn unlanded_predecessor(
2662 &self,
2663 tx: &Transaction,
2664 txid: &str,
2665 beef_bytes: &[u8],
2666 topic: &str,
2667 mode: SubmitMode,
2668 admits: bool,
2669 subject_names: &mut Option<HashSet<String>>,
2670 question_reads: &mut usize,
2671 bound: Option<&CallBound>,
2672 ) -> Option<NotNow> {
2673 let manager = self.managers.get(topic)?;
2674 let wait = |why: String| Some(NotNow { why, carried: None });
2675 // The outpoints `tx` spends, as `txid.vout`.
2676 let starts: Vec<(String, u32)> = tx
2677 .inputs
2678 .iter()
2679 .filter_map(|input| {
2680 let source = input.get_source_txid().ok()?;
2681 (!source.is_empty()).then_some((source, input.source_output_index))
2682 })
2683 .collect();
2684 // The outputs of each candidate that the walk spends, and who spends
2685 // each: a dry run asks only of an output its spender names.
2686 let mut spent_of: HashMap<String, HashSet<u32>> = HashMap::new();
2687 let mut spenders: HashMap<String, Vec<(String, u32)>> = HashMap::new();
2688 let mut ask: Vec<String> = Vec::new();
2689 for (source, vout) in &starts {
2690 if admits
2691 && subject_names
2692 .as_ref()
2693 .is_some_and(|named| !named.contains(&format!("{source}.{vout}")))
2694 {
2695 continue;
2696 }
2697 spent_of.entry(source.clone()).or_default().insert(*vout);
2698 spenders
2699 .entry(source.clone())
2700 .or_default()
2701 .push((txid.to_string(), *vout));
2702 ask.push(source.clone());
2703 }
2704 // Nothing to ask (a transaction that admits and names nothing, every
2705 // manager of this workspace): the BEEF is not even parsed.
2706 if ask.is_empty() {
2707 return None;
2708 }
2709 let direct: HashSet<String> = ask.iter().cloned().collect();
2710 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS).ok()?;
2711 // Each body the BEEF carries, and whether the BEEF says it is
2712 // proven (a bump index: its word, no proof is checked here).
2713 let bodies: HashMap<String, (&Transaction, bool)> = beef
2714 .txs
2715 .iter()
2716 .filter_map(|btx| {
2717 btx.tx()
2718 .map(|body| (btx.txid(), (body, btx.bump_index().is_some())))
2719 })
2720 .collect();
2721 // The manager's names over each walked body other than `tx`.
2722 let mut names_over: HashMap<String, HashSet<String>> = HashMap::new();
2723 // The reads spent on bodies neither proven nor found landed.
2724 let mut reads = 0usize;
2725 let mut asked: HashSet<String> = HashSet::new();
2726 while let Some(candidate) = ask.pop() {
2727 if candidate.is_empty() || !asked.insert(candidate.clone()) {
2728 continue;
2729 }
2730 let spent_outputs = spent_of.get(&candidate).cloned().unwrap_or_default();
2731 // An absent body is asked of the store only when it is one of
2732 // `tx`'s own inputs and the manager names it (E1D).
2733 let body = bodies.get(&candidate).copied();
2734 if body.is_none() {
2735 if !direct.contains(&candidate) {
2736 continue;
2737 }
2738 if subject_names.is_none() {
2739 *subject_names = Some(
2740 self.named_history(manager.as_ref(), beef_bytes, bound)
2741 .await,
2742 );
2743 }
2744 let is_named = subject_names.as_ref().is_some_and(|named| {
2745 spent_outputs
2746 .iter()
2747 .any(|vout| named.contains(&format!("{candidate}.{vout}")))
2748 });
2749 if !is_named {
2750 continue;
2751 }
2752 }
2753 let proven = body.is_some_and(|(_, proven)| proven);
2754 // This candidate's own reads: they join `reads` once it is
2755 // found not landed, and never if the BEEF proves it.
2756 let mut spent = 0usize;
2757 let out_of = |spent: usize| !proven && reads + spent >= PREDECESSOR_READS;
2758 let out_of_reads = || {
2759 wait(format!(
2760 "{candidate} is not known to have landed: the question ran out of its {PREDECESSOR_READS} reads"
2761 ))
2762 };
2763 // The submit's allowance, over every read: proven or landed,
2764 // this topic or an earlier one.
2765 let out_of_allowance = || {
2766 wait(format!(
2767 "{candidate} is not known to have landed: the question ran out of the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2768 ))
2769 };
2770 let unread = |e: StorageError| {
2771 wait(format!(
2772 "{candidate} is not known to have landed: the store could not say ({e})"
2773 ))
2774 };
2775 let carried = |why: String| {
2776 Some(NotNow {
2777 why,
2778 carried: Some(candidate.clone()),
2779 })
2780 };
2781 let record = AppliedTransaction {
2782 txid: candidate.clone(),
2783 topic: topic.to_string(),
2784 };
2785 // An applied row, or an output held, ends the question for this
2786 // transaction: it landed.
2787 if out_of(spent) {
2788 return out_of_reads();
2789 }
2790 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2791 return out_of_allowance();
2792 }
2793 spent += 1;
2794 *question_reads += 1;
2795 match stored(bound, self.storage.does_applied_transaction_exist(&record)).await {
2796 Ok(false) => {}
2797 Ok(true) => continue,
2798 Err(e) => return unread(e),
2799 }
2800 if out_of(spent) {
2801 return out_of_reads();
2802 }
2803 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2804 return out_of_allowance();
2805 }
2806 spent += 1;
2807 *question_reads += 1;
2808 match stored(
2809 bound,
2810 self.storage.find_outputs_for_transaction(&candidate, false),
2811 )
2812 .await
2813 {
2814 Ok(outputs) if outputs.iter().all(|o| o.topic != topic) => {}
2815 Ok(_) => continue,
2816 Err(e) => return unread(e),
2817 }
2818 // Not landed. With no body to walk, the manager's word is the
2819 // whole answer (E1D).
2820 let Some((body, _)) = body else {
2821 return wait(format!(
2822 "{candidate} has not landed (the manager names it as history; the BEEF does not carry it)"
2823 ));
2824 };
2825 for input in &body.inputs {
2826 let source = input.get_source_txid().unwrap_or_default();
2827 if source.is_empty() {
2828 continue;
2829 }
2830 if out_of(spent) {
2831 return out_of_reads();
2832 }
2833 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2834 return out_of_allowance();
2835 }
2836 spent += 1;
2837 *question_reads += 1;
2838 let coin = stored(
2839 bound,
2840 self.storage.find_output(
2841 &source,
2842 input.source_output_index,
2843 Some(topic),
2844 None,
2845 false,
2846 ),
2847 )
2848 .await;
2849 match coin {
2850 Ok(Some(_)) => return carried(format!("{candidate} has not landed")),
2851 // Not held: its own transaction may be the one that has
2852 // not landed.
2853 Ok(None) => {
2854 spent_of
2855 .entry(source.clone())
2856 .or_default()
2857 .insert(input.source_output_index);
2858 spenders
2859 .entry(source.clone())
2860 .or_default()
2861 .push((candidate.clone(), input.source_output_index));
2862 ask.push(source);
2863 }
2864 Err(e) => return unread(e),
2865 }
2866 }
2867 // It spends no coin the topic holds. Does a spender NAME an
2868 // output the walk spends of it (the lens fold, H1)? A names call
2869 // per spender, no store read.
2870 let mut named_outputs: HashSet<u32> = HashSet::new();
2871 for (spender, vout) in spenders.get(&candidate).cloned().unwrap_or_default() {
2872 let names = if spender == txid {
2873 if subject_names.is_none() {
2874 *subject_names = Some(
2875 self.named_history(manager.as_ref(), beef_bytes, bound)
2876 .await,
2877 );
2878 }
2879 subject_names.as_ref()
2880 } else {
2881 if !names_over.contains_key(&spender) {
2882 let names = match beef
2883 .find_atomic_transaction(&spender)
2884 .and_then(|spender| spender.to_atomic_beef(true).ok())
2885 {
2886 Some(spender_beef) => {
2887 self.named_history(manager.as_ref(), &spender_beef, bound)
2888 .await
2889 }
2890 None => HashSet::new(),
2891 };
2892 names_over.insert(spender.clone(), names);
2893 }
2894 names_over.get(&spender)
2895 };
2896 if names.is_some_and(|named| named.contains(&format!("{candidate}.{vout}"))) {
2897 named_outputs.insert(vout);
2898 }
2899 }
2900 if !named_outputs.is_empty() {
2901 // Would the manager admit a named output of it with no coins
2902 // (E1D, the opener)? A dry run over the candidate built out
2903 // of the BEEF parsed above (its proof and, unproven, its
2904 // sources linked): no store read, nothing counted.
2905 let judged = beef
2906 .find_atomic_transaction(&candidate)
2907 .unwrap_or_else(|| body.clone());
2908 let verdict = bounded(
2909 bound,
2910 manager.identify_admissible_outputs(
2911 &judged,
2912 &[],
2913 None,
2914 mode,
2915 &TopicAdmittanceContext::DRY_RUN,
2916 ),
2917 )
2918 .await;
2919 match verdict {
2920 Ok(Ok(admittance)) => {
2921 if admittance
2922 .outputs_to_admit
2923 .iter()
2924 .any(|vout| named_outputs.contains(vout))
2925 {
2926 return carried(format!(
2927 "{candidate} has not landed (the manager names the output spent from it and would admit it with no coin: an opener)"
2928 ));
2929 }
2930 }
2931 Ok(Err(TopicManagerError::NoAdmissibleOutputs(_))) => {}
2932 Ok(Err(e)) => {
2933 return wait(format!(
2934 "{candidate} is not known to have landed: the manager could not judge it ({e})"
2935 ))
2936 }
2937 Err(no_answer) => {
2938 return wait(format!(
2939 "{candidate} is not known to have landed: the manager did not answer ({no_answer})"
2940 ))
2941 }
2942 }
2943 }
2944 if !proven {
2945 reads += spent;
2946 }
2947 }
2948 None
2949 }
2950
2951 /// The outpoints (`txid.vout`) the topic manager NAMES as overlay
2952 /// history of the transaction `beef_bytes` names (`identify_needed_inputs`,
2953 /// D13's word; an atomic BEEF, so a manager that parses
2954 /// `from_beef(_, None)` reads that transaction). An `Err` or no answer
2955 /// names nothing, as the GASP walk treats it (logged).
2956 async fn named_history(
2957 &self,
2958 manager: &dyn TopicManager,
2959 beef_bytes: &[u8],
2960 bound: Option<&CallBound>,
2961 ) -> HashSet<String> {
2962 match bounded(bound, manager.identify_needed_inputs(beef_bytes, None)).await {
2963 Ok(Ok(named)) => named.iter().map(Outpoint::to_graph_id).collect(),
2964 Ok(Err(e)) => {
2965 warn!("the predecessor question: the manager could not name its inputs: {e}");
2966 HashSet::new()
2967 }
2968 Err(no_answer) => {
2969 warn!("the predecessor question: the manager did not name its inputs: {no_answer}");
2970 HashSet::new()
2971 }
2972 }
2973 }
2974
2975 /// The reference's `tx.verify(chainTracker)` on submit: every unproven
2976 /// transaction from the subject down has every input's script executed
2977 /// against its source output and obeys the value rule; a proven one is
2978 /// not descended, and its BUMP's root is checked against the chain
2979 /// tracker (computed and accepted with none).
2980 ///
2981 /// Since bsv-low #592 (the owner's ruling 3a of 2026-10-10) the scripts
2982 /// are walked by the DOOR's stream walk ([`crate::script_door`]) under
2983 /// `budget`, the engine's [`DoorBudget`]: one walker, the same estimates
2984 /// charged from the bytes before anything is parsed or run. Before it this
2985 /// was a walk of its own over a hydrated `Beef` with no memory charge and
2986 /// no interpreter limit: 219.7 MB natively on a 1.9 MB push-only unproven
2987 /// parent, an isolate kill on Workers, met by every `historical-tx` replay
2988 /// of the queue. The roots are then asked for the proven transactions the
2989 /// walk reached, in its order, once per BUMP.
2990 ///
2991 /// `Ok(Some(_))` is a walk that COULD NOT RUN within the budget, never a
2992 /// refusal (below, at the match). The interpreter's refusal and a
2993 /// structural fault are what they were: `ScriptVerificationFailed`, and
2994 /// `SpvError` in the same words.
2995 ///
2996 /// The tracker is an ARGUMENT, not `self`'s: the GASP anchor check
2997 /// ([`verify_spv_like_the_reference`]) runs this same walk from
2998 /// `OverlayGASPStorage`, which borrows the engine's tracker.
2999 ///
3000 /// Why not bsv-rs `Transaction::verify`: it walks `source_transaction`
3001 /// links that `from_beef` builds by CLONING, once per input. Two inputs
3002 /// sourcing the same unproven parent (a covenant head output plus that
3003 /// spend's own change, the shape of every second head spend of a wallet)
3004 /// leave the second clone bare ("Input 0 has no source transaction"), and
3005 /// re-linking every clone materializes the ancestry EXPONENTIALLY along a
3006 /// diamond chain: a 12-deep unmined chain of head spends took a Worker past
3007 /// its memory on beta (zanaadu, 2026-09-08). The stream walk reads each
3008 /// transaction once, in place, keyed by txid.
3009 async fn verify_beef_linear(
3010 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3011 beef_bytes: &[u8],
3012 subject_txid: &str,
3013 trusted: &HashSet<String>,
3014 budget: DoorBudget,
3015 ) -> Result<Option<WalkCouldNotRun>, EngineError> {
3016 // A structural fault is the reference walk's `SpvError`.
3017 let spv =
3018 |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3019
3020 // THE SCRIPTS (bsv-low #592): the door's own stream walk, under the
3021 // engine's budget. One walker (Rule 10): the door and the submit run
3022 // the same function, the same estimates and the same interpreter
3023 // limit; the submit adds the roots below.
3024 let mut trace = crate::script_door::WalkTrace::default();
3025 match crate::script_door::walk_trusting(
3026 beef_bytes,
3027 subject_txid,
3028 budget,
3029 trusted,
3030 &mut trace,
3031 ) {
3032 Ok(_) => {}
3033 // The interpreter's verdict, the reference's refusal: unchanged.
3034 Err(refused @ EngineError::ScriptVerificationFailed { .. }) => return Err(refused),
3035 // A structural fault (a source missing, the value rule, ...):
3036 // the reference walk's `SpvError`, in the same words as before.
3037 Err(EngineError::ScriptWalkInconclusive { reason, .. }) => return Err(spv(reason)),
3038 // The budget, not the transaction: the walk COULD NOT RUN. Not a
3039 // refusal: the bytes are a BEEF the walk was not given the room
3040 // to judge, which says nothing about whether its scripts are
3041 // valid (refusing would refuse a valid BEEF for its size, the
3042 // owner's posture). Not an admission either: the caller's
3043 // [`WalkBreachPolicy`] decides, and only the network's word on
3044 // record lets the submit go on (the E592 lens fold, H1). The roots
3045 // the walk reached are checked first, below.
3046 Err(EngineError::ScriptWalkOverBudget {
3047 at_txid,
3048 subject_judged,
3049 limb,
3050 what,
3051 }) => {
3052 let limb = match limb {
3053 DoorLimb::Memory => WalkLimb::OverMemory,
3054 DoorLimb::Work if trace.interpreter_tripped => WalkLimb::InterpreterMemory,
3055 DoorLimb::Work => WalkLimb::OverWork,
3056 };
3057 // THE ROOTS OF A BREACHED WALK (the E592 lens fold, H1): every
3058 // proven transaction it reached, and every proven source an
3059 // input read, whatever the scripts' verdict. A bad root is
3060 // the `SpvError` it is on a walk that ran: a breach never
3061 // skips a proof the walk relied on. Their cost is a tracker
3062 // question per BUMP, which the budget does not bound and
3063 // does not need to (the clean walk asks the same).
3064 let reached: Vec<String> = trace
3065 .proven
3066 .iter()
3067 .chain(&trace.proven_sources)
3068 .cloned()
3069 .collect();
3070 Self::check_reached_roots(chain_tracker, beef_bytes, &reached).await?;
3071 return Ok(Some(WalkCouldNotRun {
3072 subject_txid: subject_txid.to_string(),
3073 at_txid,
3074 subject_judged,
3075 limb,
3076 what,
3077 }));
3078 }
3079 Err(other) => return Err(other),
3080 }
3081
3082 // THE ROOTS: every proven transaction the walk reached, in the order
3083 // it reached them, its BUMP's root computed and asked of the tracker
3084 // once per BUMP (NL-6e). With no tracker the roots are computed and
3085 // accepted (`'scripts only'`). The walk trusted them as it went; the
3086 // reference checks them in the same walk, so a body with BOTH a bad
3087 // script and a bad root now names the script.
3088 Self::check_reached_roots(chain_tracker, beef_bytes, &trace.proven).await?;
3089 Ok(None)
3090 }
3091
3092 /// The roots of `reached` (display txids, in order; a repeat or a second
3093 /// transaction of one BUMP asks nothing more), each BUMP's root computed
3094 /// and asked of the tracker once. None reached: the body is not parsed.
3095 async fn check_reached_roots(
3096 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3097 beef_bytes: &[u8],
3098 reached: &[String],
3099 ) -> Result<(), EngineError> {
3100 let spv =
3101 |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3102 if reached.is_empty() {
3103 return Ok(());
3104 }
3105 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
3106 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3107 let proven_in = proven_index(&beef);
3108 let mut checked: HashSet<usize> = HashSet::new();
3109 for txid in reached {
3110 let Some(&at) = proven_in.get(txid.as_str()) else {
3111 return Err(spv(format!("transaction {txid} is not in the BEEF")));
3112 };
3113 if !checked.insert(at) {
3114 continue; // its BUMP's root is the chain's, asked once
3115 }
3116 let mp = &beef.bumps[at];
3117 let root = mp
3118 .compute_root(Some(txid))
3119 .map_err(|e| spv(format!("invalid merkle path for transaction {txid}: {e}")))?;
3120 if let Some(tracker) = chain_tracker {
3121 match tracker
3122 .is_valid_root_for_height(&root, mp.block_height)
3123 .await
3124 {
3125 Ok(true) => {}
3126 Ok(false) => {
3127 return Err(EngineError::SpvError(format!(
3128 "Invalid merkle path for transaction {txid}: root {root} is not valid for block height {}",
3129 mp.block_height
3130 )));
3131 }
3132 Err(e) => {
3133 return Err(EngineError::SpvError(format!(
3134 "Chain tracker error at height {}: {e}",
3135 mp.block_height
3136 )));
3137 }
3138 }
3139 }
3140 }
3141 Ok(())
3142 }
3143
3144 /// The pre-2026-09-08 SPV block, kept as the
3145 /// [`Engine::set_script_verification`] `false` escape hatch: BEEF
3146 /// structural validity plus every root against the chain tracker, and
3147 /// NOTHING when no tracker is configured. It never executes a script.
3148 ///
3149 /// The structure is the streaming reader's
3150 /// ([`beef_limits::structure_roots`], bsv-rs 0.4.2): one element in hand,
3151 /// each BUMP walked once, linear in its leaves (NL-6e). Until NL-6e it
3152 /// was `Beef::verify_valid`, whose root check at bsv-rs 0.4.0 walked a
3153 /// BUMP once per leaf: one BUMP of 8,192 leaves gave no answer in 120 s
3154 /// (bsv-stack-lean NL-8 W1). The reader reads the wire's order, where
3155 /// `verify_valid` sorted first: an input that names a later transaction
3156 /// is refused here.
3157 async fn verify_spv_structurally(
3158 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3159 beef_bytes: &[u8],
3160 ) -> Result<(), EngineError> {
3161 let Some(chain_tracker) = chain_tracker else {
3162 return Ok(());
3163 };
3164 let roots = beef_limits::structure_roots(beef_bytes)
3165 .map_err(|r| EngineError::SpvError(format!("BEEF structure refused: {r}")))?;
3166 let mut asked: HashSet<(u64, &str)> = HashSet::new();
3167 for (height, root) in &roots {
3168 if !asked.insert((*height, root.as_str())) {
3169 continue;
3170 }
3171 let Ok(height) = u32::try_from(*height) else {
3172 return Err(EngineError::SpvError(format!(
3173 "Merkle root {root} invalid for block height {height}"
3174 )));
3175 };
3176 match chain_tracker.is_valid_root_for_height(root, height).await {
3177 Ok(true) => {}
3178 Ok(false) => {
3179 return Err(EngineError::SpvError(format!(
3180 "Merkle root {root} invalid for block height {height}"
3181 )));
3182 }
3183 Err(e) => {
3184 return Err(EngineError::SpvError(format!(
3185 "Chain tracker error at height {height}: {e}"
3186 )));
3187 }
3188 }
3189 }
3190 Ok(())
3191 }
3192
3193 /// Phase 1 of ONE topic: the dedup read, the previous coins, the
3194 /// manager's judgement. Read-only. [`Engine::run_validation`] asks it of
3195 /// every topic; the door asks it again of a topic whose predecessor it
3196 /// landed first from the BEEF (lane E1D's lens fold, M1).
3197 #[allow(clippy::too_many_arguments)]
3198 async fn validate_topic(
3199 &self,
3200 tx: &Transaction,
3201 txid: &str,
3202 topic: &str,
3203 off_chain_values: Option<&[u8]>,
3204 mode: SubmitMode,
3205 context: &TopicAdmittanceContext,
3206 bound: Option<&CallBound>,
3207 ) -> TopicValidation {
3208 let tx_record = AppliedTransaction {
3209 txid: txid.to_string(),
3210 topic: topic.to_string(),
3211 };
3212 // A dedup read that FAULTS is the topic's read fault (the delta
3213 // fold of 2026-10-07, H3): nothing is judged and nothing is
3214 // written for the topic, the fault rides the report and the
3215 // replay asks again. The reference fails the topic there
3216 // (`Engine.ts` `submit`: the throw lands in the per-topic catch,
3217 // `failedTopics.add`). Read as "not a dupe", as it was, a
3218 // re-presented transaction the manager admits with no previous
3219 // coin was inserted a second time, unspent, beside the output
3220 // that had spent it, under a durable report.
3221 let is_dupe = match stored(
3222 bound,
3223 self.storage.does_applied_transaction_exist(&tx_record),
3224 )
3225 .await
3226 {
3227 Ok(is_dupe) => is_dupe,
3228 Err(e) => {
3229 error!("Error reading the applied row of {txid} for topic {topic}: {e}");
3230 return TopicValidation {
3231 topic: topic.to_string(),
3232 is_dupe: false,
3233 previous_coins: vec![],
3234 previous_outputs: vec![],
3235 admittance: AdmittanceInstructions::default(),
3236 failed: false,
3237 read_fault: Some(("does_applied_transaction_exist", e.to_string())),
3238 };
3239 }
3240 };
3241
3242 if is_dupe {
3243 return TopicValidation {
3244 topic: topic.to_string(),
3245 is_dupe: true,
3246 previous_coins: vec![],
3247 previous_outputs: vec![],
3248 admittance: AdmittanceInstructions::default(),
3249 failed: false,
3250 read_fault: None,
3251 };
3252 }
3253
3254 let mut previous_coins: Vec<u32> = Vec::new();
3255 let mut previous_outputs: Vec<Output> = Vec::new();
3256 let mut read_fault: Option<(&'static str, String)> = None;
3257
3258 for (input_idx, input) in tx.inputs.iter().enumerate() {
3259 let source_txid = input.get_source_txid().unwrap_or_default();
3260 if source_txid.is_empty() {
3261 continue;
3262 }
3263 match stored(
3264 bound,
3265 self.storage.find_output(
3266 &source_txid,
3267 input.source_output_index,
3268 Some(topic),
3269 None,
3270 false,
3271 ),
3272 )
3273 .await
3274 {
3275 Ok(Some(prev_output)) => {
3276 previous_coins.push(input_idx as u32);
3277 previous_outputs.push(prev_output);
3278 }
3279 Ok(None) => {}
3280 Err(e) => {
3281 // A faulted read is NOT "no previous coin": the
3282 // spend may consume an admitted output we simply
3283 // could not read. Validation proceeds (the topic
3284 // manager decides on what it can see) but the fault
3285 // rides the report and blocks the applied record.
3286 error!(
3287 "Error reading previous output {source_txid}:{} for topic {topic}: {e}",
3288 input.source_output_index
3289 );
3290 if read_fault.is_none() {
3291 read_fault = Some(("find_output", e.to_string()));
3292 }
3293 }
3294 }
3295 }
3296
3297 let judged = self
3298 .judge(
3299 tx,
3300 topic,
3301 &previous_coins,
3302 off_chain_values,
3303 mode,
3304 context,
3305 bound,
3306 )
3307 .await;
3308 match judged {
3309 Ok(admittance) => TopicValidation {
3310 topic: topic.to_string(),
3311 is_dupe: false,
3312 previous_coins,
3313 previous_outputs,
3314 admittance,
3315 failed: false,
3316 read_fault: read_fault.clone(),
3317 },
3318 Err(e) => {
3319 error!("Error validating topic {topic} during submit: {e}");
3320 TopicValidation {
3321 topic: topic.to_string(),
3322 is_dupe: false,
3323 previous_coins: vec![],
3324 previous_outputs: vec![],
3325 admittance: AdmittanceInstructions::default(),
3326 failed: true,
3327 read_fault,
3328 }
3329 }
3330 }
3331 }
3332
3333 /// The topic manager's judgement of `tx` over the previous coins found
3334 /// (input indices), as [`Engine::validate_topic`] asks it; a carried
3335 /// predecessor's submit asks it again for real once its topic does not
3336 /// wait (the E1D delta fold, L4).
3337 #[allow(clippy::too_many_arguments)]
3338 async fn judge(
3339 &self,
3340 tx: &Transaction,
3341 topic: &str,
3342 previous_coins: &[u32],
3343 off_chain_values: Option<&[u8]>,
3344 mode: SubmitMode,
3345 context: &TopicAdmittanceContext,
3346 bound: Option<&CallBound>,
3347 ) -> Result<AdmittanceInstructions, TopicManagerError> {
3348 let manager = &self.managers[topic];
3349 let previous_coin_bytes = previous_coins
3350 .iter()
3351 .flat_map(|i| i.to_le_bytes())
3352 .collect::<Vec<u8>>();
3353 // Under a finalize submit's bound a manager that does not answer
3354 // is a manager that failed: nothing is written for the topic.
3355 bounded(
3356 bound,
3357 manager.identify_admissible_outputs(
3358 // The ONE engine parse of the submitted BEEF, shared by
3359 // every topic on this submit (bsv-low #289 — managers
3360 // used to re-parse the same bytes independently).
3361 tx,
3362 &previous_coin_bytes,
3363 off_chain_values,
3364 mode,
3365 // `dry_run` is `false` on a submit, `true` on a
3366 // validate-only call.
3367 context,
3368 ),
3369 )
3370 .await
3371 .map_err(TopicManagerError::Other)
3372 .and_then(|judged| judged)
3373 }
3374
3375 /// Whether the door may land a carried predecessor into `topic` (the
3376 /// E1D delta fold, L1): neither its manager nor any lookup service
3377 /// reads off-chain values there, which the landing does not have.
3378 fn lands_without_values(&self, topic: &str) -> bool {
3379 self.managers
3380 .get(topic)
3381 .is_some_and(|manager| !manager.reads_off_chain_values())
3382 && self
3383 .lookup_services
3384 .values()
3385 .all(|ls| !ls.reads_off_chain_values(topic))
3386 }
3387
3388 /// Run Phase 1 (topic validation) and Phase 2 (broadcast) without mutating storage.
3389 ///
3390 /// Returns (validations, steak, parsed_tx, txid) so the caller can either
3391 /// stop (validate-only) or proceed with Phase 3 (mutations).
3392 async fn run_validation(
3393 &self,
3394 tagged_beef: &TaggedBEEF,
3395 mode: SubmitMode,
3396 context: &TopicAdmittanceContext,
3397 bound: Option<&CallBound>,
3398 walked: Option<&HashSet<String>>,
3399 policy: WalkBreachPolicy,
3400 ) -> Result<Validated, EngineError> {
3401 // Validate all topics are supported
3402 for topic in &tagged_beef.topics {
3403 if !self.managers.contains_key(topic) {
3404 return Err(EngineError::UnsupportedTopic(topic.clone()));
3405 }
3406 }
3407
3408 // Parse the SUBJECT from the BEEF — order-independent (`subject.rs`,
3409 // loop-2 hardening 2026-09-05): `from_beef(_, None)` takes the last
3410 // tx in wire order, which an SDK-sorted INCOMPLETE ancestry fills
3411 // with a fully-sourced ancestor; the engine then judged a hop, admitted
3412 // nothing and the pot never entered the index while its JOIN mined.
3413 let subject = {
3414 let mut b =
3415 beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
3416 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3417 crate::subject::subject_txid_of(&mut b)
3418 };
3419 let tx = beef_limits::transaction_from_beef(
3420 &tagged_beef.beef,
3421 subject.as_deref(),
3422 &beef_limits::ENGINE_BEEF_LIMITS,
3423 )
3424 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3425 let txid = tx.id();
3426
3427 // SPV verification, skipped ONLY for HistoricalTxNoSpv, exactly the
3428 // reference's `if (mode !== 'historical-tx-no-spv') tx.verify(...)`.
3429 // That mode exists for GASP `finalizeGraph`, whose graphs
3430 // `validateGraphAnchor` already verified, and since bsv-low #551 that
3431 // is TRUE here: `OverlayGASPStorage::validate_graph_anchor` runs this
3432 // same check ([`verify_spv_like_the_reference`], the same tracker and
3433 // the same switch) over the ROOT node's BEEF and replays the graph
3434 // through the topic manager before a single BEEF of it reaches this
3435 // door. Before #551 the anchor check was a no-op and this skip
3436 // admitted a peer's graph on structure alone.
3437 //
3438 // A predecessor the door lands first (`walked`, the E1D delta fold,
3439 // L4) is not walked again when this submit's walks already passed
3440 // over it, and its walk trusts what they passed over.
3441 //
3442 // The walk runs under the engine's budget (bsv-low #592,
3443 // [`Engine::set_walk_budget`]): one that could not run within it is
3444 // NOT a refusal (see `verify_beef_linear`), and NOT an admission
3445 // unless the caller holds the network's accept of these bytes (the
3446 // E592 lens fold, H1, [`WalkBreachPolicy`]): "not now" before any
3447 // manager is asked, or, under `NetworkAccepted`, this submit goes on
3448 // as under `HistoricalTxNoSpv` and the report says so.
3449 let mut could_not_run = None;
3450 if mode != SubmitMode::HistoricalTxNoSpv {
3451 match walked {
3452 Some(walked) if walked.contains(&txid) => {}
3453 trusted => {
3454 could_not_run = verify_spv_trusting(
3455 self.chain_tracker.as_deref(),
3456 self.verify_scripts,
3457 &tagged_beef.beef,
3458 &txid,
3459 trusted.unwrap_or(&HashSet::new()),
3460 self.walk_budget,
3461 )
3462 .await?;
3463 }
3464 }
3465 }
3466 if let Some(stop) = could_not_run.take() {
3467 match policy {
3468 WalkBreachPolicy::NotNow => {
3469 warn!(
3470 "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): not now, nothing judged or written; present it proven (bsv-low #592, E592 fold)",
3471 stop.subject_txid,
3472 stop.limb.as_str(),
3473 stop.at_txid,
3474 stop.subject_judged,
3475 stop.what
3476 );
3477 return Err(EngineError::WalkCouldNotRun(stop));
3478 }
3479 WalkBreachPolicy::NetworkAccepted => {
3480 warn!(
3481 "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): the network accepted these bytes, the submit goes on as historical-tx-no-spv (bsv-low #592)",
3482 stop.subject_txid,
3483 stop.limb.as_str(),
3484 stop.at_txid,
3485 stop.subject_judged,
3486 stop.what
3487 );
3488 could_not_run = Some(stop);
3489 }
3490 }
3491 }
3492
3493 let mut steak = Steak::new();
3494
3495 // =============================================================
3496 // PHASE 1: VALIDATE (read-only)
3497 // =============================================================
3498 let mut validations = Vec::new();
3499
3500 for topic in &tagged_beef.topics {
3501 validations.push(
3502 self.validate_topic(
3503 &tx,
3504 &txid,
3505 topic,
3506 tagged_beef.off_chain_values.as_deref(),
3507 mode,
3508 context,
3509 bound,
3510 )
3511 .await,
3512 );
3513 }
3514
3515 // Build preliminary STEAK
3516 for v in &validations {
3517 steak.insert(v.topic.clone(), v.admittance.clone());
3518 }
3519
3520 // =============================================================
3521 // PHASE 2: BROADCAST / SHIP propagation (before mutations)
3522 // =============================================================
3523 if mode == SubmitMode::CurrentTx {
3524 // ── ARC network broadcast ──────────────────────────────────
3525 // Broadcast to miners via ARC, matching the TS Engine pattern.
3526 // Skip if the tx already has a merkle path (already mined).
3527 //
3528 // INCIDENT D1-CALLBACK-FLOOD 2026-09-01: also skip when EVERY
3529 // topic classified the tx as a dupe (already applied) — the
3530 // exact `!v.is_dupe` filter the SHIP arm below has always had,
3531 // ten lines away. Without it, every RE-PRESENT of an
3532 // already-applied tx (cron ad-sync, peer crawl, a client
3533 // retrying a default-mode /submit) re-POSTed to Arcade and
3534 // re-registered the status callback, and registrations
3535 // accumulate per POST on Arcade's side — the flood's fuel. A
3536 // genuinely new tx (any topic non-dupe) broadcasts exactly as
3537 // before.
3538 let all_topics_dupe = !validations.is_empty() && validations.iter().all(|v| v.is_dupe);
3539 if let Some(ref arc) = self.arc_broadcaster {
3540 if all_topics_dupe {
3541 info!(
3542 "Skipping ARC broadcast — tx already applied to every topic (re-present)"
3543 );
3544 } else if tx.merkle_path.is_none() {
3545 let raw_tx_hex = tx.to_hex();
3546 match arc.broadcast(&raw_tx_hex).await {
3547 Ok(arc_txid) => {
3548 info!("ARC broadcast succeeded: txid={arc_txid}");
3549 }
3550 Err(e) => {
3551 error!("ARC broadcast failed (non-fatal): {e}");
3552 }
3553 }
3554 } else {
3555 info!("Skipping ARC broadcast — tx already has merkle proof");
3556 }
3557 }
3558
3559 // ── SHIP peer propagation ──────────────────────────────────
3560 if let Some(ref broadcaster) = self.broadcaster {
3561 let relevant_topics: Vec<String> = validations
3562 .iter()
3563 .filter(|v| {
3564 !v.is_dupe && !v.failed && !v.admittance.outputs_to_admit.is_empty()
3565 })
3566 .map(|v| v.topic.clone())
3567 .collect();
3568
3569 if !relevant_topics.is_empty() {
3570 if let Some(ship_ls) = self.lookup_services.get("ls_ship") {
3571 for topic in &relevant_topics {
3572 let question = LookupQuestion::new(
3573 "ls_ship",
3574 serde_json::json!({ "topics": [topic] }),
3575 );
3576 match ship_ls.lookup(&question).await {
3577 Ok(LookupResult::OutputList(refs)) => {
3578 for reference in &refs {
3579 if let Ok(Some(output)) = self
3580 .storage
3581 .find_output(
3582 &reference.txid,
3583 reference.output_index,
3584 None,
3585 None,
3586 false,
3587 )
3588 .await
3589 {
3590 if let Some(domain) =
3591 parse_ship_domain_from_script(&output.output_script)
3592 {
3593 if let Some(ref our_url) = self.config.hosting_url {
3594 if domain.trim_end_matches('/')
3595 == our_url.trim_end_matches('/')
3596 {
3597 continue;
3598 }
3599 }
3600 if let Err(e) = broadcaster
3601 .broadcast_to_host(&domain, tagged_beef)
3602 .await
3603 {
3604 error!(
3605 "SHIP propagation to {domain} failed for topic {topic}: {e}"
3606 );
3607 }
3608 }
3609 }
3610 }
3611 }
3612 Ok(LookupResult::Answer(_)) => {
3613 // SHIP only ever returns OutputList; an Answer here means
3614 // a misconfigured replacement service. Skip rather than
3615 // attempt to extract refs from a freeform/formula payload.
3616 error!(
3617 "SHIP lookup for topic {topic} returned a pre-formed \
3618 LookupAnswer; expected OutputList. Skipping fanout."
3619 );
3620 }
3621 Err(e) => {
3622 error!("SHIP lookup for topic {topic} failed: {e}");
3623 }
3624 }
3625 }
3626 }
3627 }
3628 }
3629 }
3630
3631 Ok((validations, steak, tx, txid, could_not_run))
3632 }
3633
3634 // ========================================================================
3635 // Lookup
3636 // ========================================================================
3637
3638 /// Answer a lookup query.
3639 ///
3640 /// Delegates to the appropriate LookupService, then hydrates results with BEEF.
3641 ///
3642 /// When `history_selector` is provided, each output is hydrated with its
3643 /// ancestor spend chain (via `get_utxo_history`) before building the response.
3644 /// This matches the TS Engine behavior when a history selector is configured.
3645 pub async fn lookup(
3646 &self,
3647 question: &LookupQuestion,
3648 history_selector: Option<HistorySelector>,
3649 ) -> Result<LookupAnswer, EngineError> {
3650 Ok(self.lookup_with_txids(question, history_selector).await?.0)
3651 }
3652
3653 /// Same as [`Engine::lookup`], additionally returning each hydrated
3654 /// output's txid, aligned index-for-index with the `OutputList` items
3655 /// (empty for pre-formed `Answer`s).
3656 ///
3657 /// The txid IS the storage primary key the row was just fetched by —
3658 /// handing it to the caller lets the aggregated wire serializer write it
3659 /// directly instead of re-deriving it with a full BEEF parse plus a
3660 /// double-SHA256 per output (bsv-low #289).
3661 pub async fn lookup_with_txids(
3662 &self,
3663 question: &LookupQuestion,
3664 history_selector: Option<HistorySelector>,
3665 ) -> Result<(LookupAnswer, Vec<String>), EngineError> {
3666 let service = self
3667 .lookup_services
3668 .get(&question.service)
3669 .ok_or_else(|| EngineError::LookupServiceNotFound(question.service.clone()))?;
3670
3671 // Preserve WHOSE fault it was. A lookup service reports a malformed
3672 // query as `InvalidQuery` / `Unsupported`; stringifying both into
3673 // `LookupFailed` (as this did) threw that away and reported every
3674 // caller mistake as a 500.
3675 let result = service.lookup(question).await.map_err(|e| match e {
3676 LookupServiceError::InvalidQuery(m) | LookupServiceError::Unsupported(m) => {
3677 EngineError::InvalidQuery(m)
3678 }
3679 other => EngineError::LookupFailed(other.to_string()),
3680 })?;
3681
3682 // Two paths per LookupResult:
3683 // - OutputList(refs): the LS yields outpoints; we hydrate each with
3684 // BEEF (and optional ancestor chain via history_selector) and
3685 // assemble LookupAnswer::OutputList.
3686 // - Answer(answer): the LS already produced the full answer
3687 // (Freeform/Formula); pass through verbatim. The Engine does NOT
3688 // apply history-selector hydration to a pre-formed Answer — the
3689 // LS is presumed to have embedded whatever ancestry it wants.
3690 let refs = match result {
3691 LookupResult::OutputList(refs) => refs,
3692 LookupResult::Answer(answer) => return Ok((answer, Vec::new())),
3693 };
3694
3695 // ONE batched hydration query for the whole result set instead of a
3696 // D1 round-trip per row (the #289 N+1). Order is preserved: the
3697 // batch contract returns outputs in input-outpoint order, skipping
3698 // outpoints with no stored row. A storage error is a REAL error now
3699 // (it used to be swallowed per-row into a silently-empty list — the
3700 // "vanishing table" failure mode): an uncertain read must never
3701 // masquerade as a confidently-empty lobby.
3702 let outpoints: Vec<Outpoint> = refs
3703 .iter()
3704 .map(|r| Outpoint::new(&r.txid, r.output_index))
3705 .collect();
3706 let found = self
3707 .storage
3708 .find_outputs_by_outpoints(&outpoints, true)
3709 .await
3710 .map_err(|e| EngineError::StorageError(e.to_string()))?;
3711
3712 let mut outputs = Vec::new();
3713 let mut txids = Vec::new();
3714 for output in found {
3715 // If history selector provided, hydrate ancestor chain
3716 let final_output = if history_selector.is_some() {
3717 match self
3718 .get_utxo_history(&output, history_selector.clone())
3719 .await
3720 {
3721 Ok(Some(hydrated)) => hydrated,
3722 _ => output,
3723 }
3724 } else {
3725 output
3726 };
3727
3728 // Skip an output whose BEEF is missing OR empty: a lookup item
3729 // without decodable BEEF is useless to the client (it can only
3730 // drop it), and returning an empty-BEEF row is what made a fresh
3731 // LOW table show up in a query yet be undecodable/invisible to
3732 // opponents (the "vanishing table" — 2026-07-11). Only ever
3733 // return fully-hydrated, decodable outputs.
3734 if let Some(beef) = final_output.beef {
3735 if !beef.is_empty() {
3736 txids.push(final_output.txid.clone());
3737 outputs.push(OutputListItem {
3738 beef,
3739 output_index: final_output.output_index,
3740 context: None,
3741 });
3742 }
3743 }
3744 }
3745
3746 Ok((LookupAnswer::OutputList { outputs }, txids))
3747 }
3748
3749 // ========================================================================
3750 // Metadata
3751 // ========================================================================
3752
3753 // ========================================================================
3754 // UTXO History
3755 // ========================================================================
3756
3757 /// Traverse and return the history of a UTXO.
3758 ///
3759 /// If no `history_selector` is provided, returns the output as-is.
3760 /// If a depth selector is provided, includes up to N levels of ancestor
3761 /// transactions by embedding them as source_transactions in the BEEF.
3762 pub async fn get_utxo_history(
3763 &self,
3764 output: &Output,
3765 history_selector: Option<HistorySelector>,
3766 ) -> Result<Option<Output>, EngineError> {
3767 let Some(selector) = history_selector else {
3768 return Ok(Some(output.clone()));
3769 };
3770
3771 // Verify BEEF exists before attempting hydration
3772 if output.beef.is_none() {
3773 return Err(EngineError::Other(
3774 "Output must have associated transaction BEEF!".into(),
3775 ));
3776 }
3777
3778 match self.hydrate_utxo_history(output, &selector, 0).await {
3779 Ok(Some(hydrated_output)) => Ok(Some(hydrated_output)),
3780 Ok(None) => Ok(None),
3781 Err(e) => Err(EngineError::Other(format!(
3782 "Error retrieving UTXO history: {e}"
3783 ))),
3784 }
3785 }
3786
3787 /// Recursive UTXO history hydration.
3788 fn hydrate_utxo_history<'a>(
3789 &'a self,
3790 output: &'a Output,
3791 selector: &'a HistorySelector,
3792 current_depth: u32,
3793 ) -> std::pin::Pin<
3794 Box<dyn std::future::Future<Output = Result<Option<Output>, EngineError>> + 'a>,
3795 > {
3796 Box::pin(async move {
3797 let beef_data = output.beef.as_ref().ok_or_else(|| {
3798 EngineError::Other("Output must have associated transaction BEEF!".into())
3799 })?;
3800
3801 // Check if we should traverse at this depth
3802 let should_traverse = match selector {
3803 HistorySelector::Depth(max_depth) => current_depth <= *max_depth,
3804 };
3805
3806 if !should_traverse {
3807 return Ok(None);
3808 }
3809
3810 // Start from the STORED BEEF, not from a re-parsed subject.
3811 //
3812 // #4: this used to be `Transaction::from_beef(beef_data, None)` followed
3813 // by `tx.to_beef(true)`, which keeps only the subject transaction and
3814 // therefore DESTROYS the stored BUMPs. Measured on prod before the fix:
3815 // `ls_low` byGameId `73de6e48…` returned 1,172 B / 1 tx / 1 bump (PROVEN)
3816 // with no header, and 676 B / 1 tx / 0 bumps with `x-history-depth: 3` —
3817 // a caller asking for MORE provenance got back a BEEF no wallet can
3818 // verify, silently, behind a 200.
3819 //
3820 // Accumulating into the stored BEEF fixes both halves: its own bumps
3821 // survive, and so does any ancestry that arrived inside the submitted
3822 // bytes but was never admitted as an output of its own (a foreign
3823 // parent), which the `outputs_consumed` walk below cannot see.
3824 let mut acc = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3825 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3826
3827 // For each consumed output, recursively hydrate and embed as source transaction
3828 for consumed in &output.outputs_consumed {
3829 if let Ok(Some(child_output)) = self
3830 .storage
3831 .find_output(&consumed.txid, consumed.output_index, None, None, true)
3832 .await
3833 {
3834 if let Ok(Some(hydrated_child)) = self
3835 .hydrate_utxo_history(&child_output, selector, current_depth + 1)
3836 .await
3837 {
3838 // MERGE the hydrated ancestor in, rather than embedding it
3839 // as a `source_transaction` on a re-parsed subject: a merge
3840 // carries the child's own BUMPs across, which is the whole
3841 // point of asking for history. An unparseable child is
3842 // skipped — never allowed to poison the accumulator.
3843 if let Some(ref child_beef) = hydrated_child.beef {
3844 if let Ok(child) = Beef::from_binary(child_beef) {
3845 acc.merge_beef(&child);
3846 }
3847 }
3848 }
3849 }
3850 }
3851
3852 Ok(Some(Output {
3853 beef: Some(acc.to_binary()),
3854 ..output.clone()
3855 }))
3856 })
3857 }
3858
3859 // ========================================================================
3860 // Metadata
3861 // ========================================================================
3862
3863 pub async fn list_topic_managers(&self) -> HashMap<String, ServiceMetadata> {
3864 let mut result = HashMap::new();
3865 for (name, manager) in &self.managers {
3866 let meta = manager.get_metadata().await;
3867 result.insert(name.clone(), meta);
3868 }
3869 result
3870 }
3871
3872 /// List all registered lookup services with their metadata.
3873 pub async fn list_lookup_service_providers(&self) -> HashMap<String, ServiceMetadata> {
3874 let mut result = HashMap::new();
3875 for (name, service) in &self.lookup_services {
3876 let meta = service.get_metadata().await;
3877 result.insert(name.clone(), meta);
3878 }
3879 result
3880 }
3881
3882 /// Get documentation for a specific topic manager.
3883 pub async fn get_documentation_for_topic_manager(&self, name: &str) -> String {
3884 match self.managers.get(name) {
3885 Some(manager) => manager.get_documentation().await,
3886 None => "No documentation found!".to_string(),
3887 }
3888 }
3889
3890 /// Get documentation for a specific lookup service.
3891 pub async fn get_documentation_for_lookup_service(&self, name: &str) -> String {
3892 match self.lookup_services.get(name) {
3893 Some(service) => service.get_documentation().await,
3894 None => "No documentation found!".to_string(),
3895 }
3896 }
3897
3898 // ========================================================================
3899 // GASP endpoints
3900 // ========================================================================
3901
3902 /// Respond to a GASP initial sync request.
3903 ///
3904 /// Returns UTXOs for the given topic since the requested score.
3905 ///
3906 /// The caller-supplied `limit` is CLAMPED to
3907 /// [`Engine::SYNC_RESPONSE_MAX_LIMIT`] (and defaulted when absent):
3908 /// `/requestSyncResponse` is a PUBLIC route, and an absent limit used to
3909 /// mean an unbounded scan of the outputs table (bsv-low #291). Bounding
3910 /// is lossless for sync correctness because the initiator
3911 /// ([`crate::gasp::GASPSync`]) pages for as long as its `since` score
3912 /// cursor advances — a truncated page is fetched by its NEXT request
3913 /// within the same run (gate finding M1: pagination must not demand a
3914 /// full page, which this clamp can make impossible).
3915 pub async fn provide_foreign_sync_response(
3916 &self,
3917 request: &GASPInitialRequest,
3918 topic: &str,
3919 ) -> Result<GASPInitialResponse, EngineError> {
3920 let limit = Self::clamp_sync_limit(request.limit);
3921 let outputs = self
3922 .storage
3923 .find_utxos_for_topic(topic, Some(request.since as f64), Some(limit), false)
3924 .await
3925 .map_err(|e| EngineError::StorageError(e.to_string()))?;
3926
3927 Ok(GASPInitialResponse {
3928 utxo_list: outputs
3929 .iter()
3930 .map(|o| GASPOutput {
3931 txid: o.txid.clone(),
3932 output_index: o.output_index,
3933 score: o.score.unwrap_or(0.0),
3934 })
3935 .collect(),
3936 since: request.since,
3937 })
3938 }
3939
3940 /// Provide a GASPNode for a specific transaction within a graph.
3941 ///
3942 /// Searches the BEEF tree of the root output (identified by graphID) for
3943 /// the requested txid. If not found in the BEEF tree, recurses through
3944 /// outputsConsumed in storage.
3945 pub async fn provide_foreign_gasp_node(
3946 &self,
3947 graph_id: &str,
3948 txid: &str,
3949 output_index: u32,
3950 ) -> Result<GASPNode, EngineError> {
3951 let root_outpoint = Outpoint::from_graph_id(graph_id)
3952 .ok_or_else(|| EngineError::Other(format!("Invalid graphID: {graph_id}")))?;
3953
3954 let root_output = self
3955 .storage
3956 .find_output(
3957 &root_outpoint.txid,
3958 root_outpoint.output_index,
3959 None,
3960 None,
3961 true,
3962 )
3963 .await
3964 .map_err(|e| EngineError::StorageError(e.to_string()))?
3965 .ok_or(EngineError::NodeNotFound)?;
3966
3967 self.hydrate_gasp_node(&root_output, graph_id, txid, output_index)
3968 .await
3969 }
3970
3971 /// Recursively search a BEEF tree for a specific txid and return a GASPNode.
3972 fn hydrate_gasp_node<'a>(
3973 &'a self,
3974 output: &'a Output,
3975 graph_id: &'a str,
3976 txid: &'a str,
3977 output_index: u32,
3978 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GASPNode, EngineError>> + 'a>>
3979 {
3980 Box::pin(async move {
3981 let beef_data = output.beef.as_ref().ok_or(EngineError::NodeNotFound)?;
3982
3983 // The stored body's SUBJECT by the same order-independent rule
3984 // (`subject.rs`) — a plain stored body's last tx can be an ancestor.
3985 let root = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3986 .ok()
3987 .and_then(|mut b| crate::subject::subject_txid_of(&mut b));
3988 let root_tx = beef_limits::transaction_from_beef(
3989 beef_data,
3990 root.as_deref(),
3991 &beef_limits::STORED_BEEF_LIMITS,
3992 )
3993 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3994
3995 // Search the transaction tree for the requested txid
3996 if let Some(node) = Self::search_tx_tree(&root_tx, graph_id, txid, output_index) {
3997 return Ok(node);
3998 }
3999
4000 // Fallback: try parsing BEEF with the target txid directly
4001 // (some BEEF structures have the tx in the list but not linked as source_transaction)
4002 if let Some(node) = Self::search_beef_for_txid(beef_data, graph_id, txid, output_index)
4003 {
4004 return Ok(node);
4005 }
4006
4007 // Not found in BEEF tree — recurse through outputsConsumed in storage
4008 for consumed in &output.outputs_consumed {
4009 if let Ok(Some(consumed_output)) = self
4010 .storage
4011 .find_output(&consumed.txid, consumed.output_index, None, None, true)
4012 .await
4013 {
4014 if let Ok(node) = self
4015 .hydrate_gasp_node(&consumed_output, graph_id, txid, output_index)
4016 .await
4017 {
4018 return Ok(node);
4019 }
4020 }
4021 }
4022
4023 // The graph root is known but neither its BEEF nor anything it
4024 // consumed holds the requested transaction. That is a definite
4025 // "not held", the same answer as an unknown root: the reference
4026 // throws `Unable to find output associated with your request!`
4027 // here and its route answers 400. A syncing peer reads the 400 as
4028 // `GASPError::NodeNotFound` and prunes a decoy input on it (the D8
4029 // rule, `GASPSync::process_incoming_node`); a 500 would read as a
4030 // fault of the moment and strand the chain behind the decoy.
4031 Err(EngineError::NodeNotFound)
4032 })
4033 }
4034
4035 /// Search a Transaction tree (via sourceTransaction links) for a specific txid.
4036 fn search_tx_tree(
4037 tx: &Transaction,
4038 graph_id: &str,
4039 target_txid: &str,
4040 output_index: u32,
4041 ) -> Option<GASPNode> {
4042 let current_txid = tx.id();
4043
4044 if current_txid == target_txid {
4045 let mut node = GASPNode {
4046 graph_id: graph_id.to_string(),
4047 raw_tx: tx.to_hex(),
4048 output_index,
4049 proof: None,
4050 tx_metadata: None,
4051 output_metadata: None,
4052 inputs: None,
4053 };
4054 if let Some(ref merkle_path) = tx.merkle_path {
4055 node.proof = Some(merkle_path.to_hex());
4056 }
4057 return Some(node);
4058 }
4059
4060 // Recurse into inputs via source_transaction
4061 for input in &tx.inputs {
4062 if let Some(ref source_tx) = input.source_transaction {
4063 if let Some(node) =
4064 Self::search_tx_tree(source_tx, graph_id, target_txid, output_index)
4065 {
4066 return Some(node);
4067 }
4068 }
4069 }
4070
4071 None
4072 }
4073
4074 /// Alternative search: try parsing the BEEF with the target txid directly.
4075 /// Some BEEF structures may not link source_transaction on inputs but
4076 /// still contain the transaction in the BEEF's transaction list.
4077 fn search_beef_for_txid(
4078 beef_data: &[u8],
4079 graph_id: &str,
4080 target_txid: &str,
4081 output_index: u32,
4082 ) -> Option<GASPNode> {
4083 // Try parsing BEEF with the specific txid
4084 if let Ok(tx) = beef_limits::transaction_from_beef(
4085 beef_data,
4086 Some(target_txid),
4087 &beef_limits::STORED_BEEF_LIMITS,
4088 ) {
4089 if tx.id() == target_txid {
4090 let mut node = GASPNode {
4091 graph_id: graph_id.to_string(),
4092 raw_tx: tx.to_hex(),
4093 output_index,
4094 proof: None,
4095 tx_metadata: None,
4096 output_metadata: None,
4097 inputs: None,
4098 };
4099 if let Some(ref merkle_path) = tx.merkle_path {
4100 node.proof = Some(merkle_path.to_hex());
4101 }
4102 return Some(node);
4103 }
4104 }
4105 None
4106 }
4107
4108 // ========================================================================
4109 // Merkle proof handling
4110 // ========================================================================
4111
4112 /// Handle a new merkle proof for a transaction.
4113 ///
4114 /// When a transaction gets mined, ARC calls back with the merkle proof.
4115 /// This method:
4116 /// 1. Finds all outputs for the txid in storage
4117 /// 2. Parses the BEEF, updates the merkle path in the transaction tree
4118 /// 3. Serializes updated BEEF back to storage
4119 /// 4. Recursively updates the consumedBy chain (outputs that spent this one)
4120 /// 5. Updates blockHeight on the outputs
4121 pub async fn handle_new_merkle_proof(
4122 &self,
4123 txid: &str,
4124 proof_hex: &str,
4125 block_height: Option<u32>,
4126 ) -> Result<(), EngineError> {
4127 let outputs = self
4128 .storage
4129 .find_outputs_for_transaction(txid, true)
4130 .await
4131 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4132
4133 if outputs.is_empty() {
4134 return Err(EngineError::Other(
4135 "Could not find matching transaction outputs for proof ingest!".into(),
4136 ));
4137 }
4138
4139 // Parse merkle proof if provided
4140 let proof = if proof_hex.is_empty() {
4141 None
4142 } else {
4143 Some(
4144 beef_limits::merkle_path_from_hex(proof_hex, beef_limits::PUSH_PROOF_MAX_BYTES)
4145 .map_err(|e| EngineError::Other(format!("Invalid merkle proof hex: {e}")))?,
4146 )
4147 };
4148
4149 for output in &outputs {
4150 // Stitch the proof into the STORED BEEF (#284) — never rebuild it
4151 // around a re-picked subject; see stitch_proof_into_stored_beef.
4152 if let (Some(ref beef_data), Some(ref proof)) = (&output.beef, &proof) {
4153 if let Some(new_beef) = Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4154 {
4155 let _ = self
4156 .storage
4157 .update_transaction_beef(&output.txid, &new_beef)
4158 .await;
4159 }
4160 }
4161
4162 // Update block height
4163 if let Some(height) = block_height {
4164 let _ = self
4165 .storage
4166 .update_output_block_height(
4167 &output.txid,
4168 output.output_index,
4169 &output.topic,
4170 height,
4171 )
4172 .await;
4173 }
4174
4175 // Recursively update consumedBy chain
4176 for consuming in &output.consumed_by {
4177 if let Ok(consumed_outputs) = self
4178 .storage
4179 .find_outputs_for_transaction(&consuming.txid, true)
4180 .await
4181 {
4182 for consumed_output in &consumed_outputs {
4183 // The consuming row's BEEF carries this tx as an
4184 // ancestor — stitch the same bump in there too, again
4185 // without rebuilding the tx set (#284: this branch is
4186 // the one that was observed replacing a head-spend's
4187 // stored BEEF with parent ancestry only).
4188 if let (Some(ref beef_data), Some(ref proof)) =
4189 (&consumed_output.beef, &proof)
4190 {
4191 if let Some(new_beef) =
4192 Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4193 {
4194 let _ = self
4195 .storage
4196 .update_transaction_beef(&consumed_output.txid, &new_beef)
4197 .await;
4198 }
4199 }
4200 }
4201 }
4202 }
4203 }
4204
4205 Ok(())
4206 }
4207
4208 /// Complete missing merkle proofs for stored, confirmed transactions.
4209 ///
4210 /// The `/submit`-admitted ingest path stores a proofless BEEF for each
4211 /// transaction (the submitter does not yet have a merkle proof at submit
4212 /// time). When such a transaction later mines, nothing in the overlay
4213 /// re-fetches its proof, so its stored BEEF stays proofless forever — and a
4214 /// frontend that trims `inputBEEF` to `{source tx + its BUMP}` for a P2PKH
4215 /// payment-import spend has no BUMP to trim to, hanging the import (#130).
4216 ///
4217 /// This pass closes that gap from chain (the one blessed server-side WoC
4218 /// use). It:
4219 /// 1. Pulls a bounded page (`limit`) of stored `(txid, beef)` from storage.
4220 /// 2. Parses each BEEF and keeps only those whose target tx still lacks a
4221 /// merkle proof (`Beef::find_txid(txid).has_proof() == false`).
4222 /// 3. For each proofless candidate, fetches its BUMP via the configured
4223 /// [`AncestorFetcher`](crate::gasp::AncestorFetcher). If a proof comes
4224 /// back, it calls [`Self::handle_new_merkle_proof`], which stitches the
4225 /// BUMP into the stored BEEF (D1 + R2 mirror), updates output block
4226 /// height, and recurses the `consumedBy` chain.
4227 /// 4. Skips (does NOT error) txs the fetcher can't prove yet (still
4228 /// unconfirmed) — those are simply retried on a later tick.
4229 ///
4230 /// **No-op when no [`AncestorFetcher`] is configured** — production-safe
4231 /// default (the same opt-in switch as GASP ancestor hydration). Bounded by
4232 /// `limit` (a per-tick budget, like the platform fetcher's own budget).
4233 ///
4234 /// `min_age_secs` is the PUSH-PRIMARY BACKSTOP gate (bsv-low #228 /
4235 /// arcade#259): since the Arcade MINED webhook (`/arc-ingest`) pushes a
4236 /// verified proof ~150 ms after a tx mines, this poll pass is a BACKSTOP,
4237 /// not the primary source — rows stored less than `min_age_secs` ago are
4238 /// skipped entirely (their push is still expected). Pass `0` to disable
4239 /// the gate (poll everything — the pre-#228 behaviour, and the degradation
4240 /// mode if pushes stop: an old-enough row is ALWAYS polled, so webhook
4241 /// loss degrades to polling, never to nothing). Unknown-age rows are
4242 /// always eligible (fail-safe — see the storage-trait doc).
4243 pub async fn complete_missing_proofs(
4244 &self,
4245 limit: u64,
4246 min_age_secs: u64,
4247 ) -> Result<ProofCompletionSummary, EngineError> {
4248 let mut summary = ProofCompletionSummary::default();
4249
4250 // Production-safe: with no fetcher there is no chain source to complete
4251 // from, so this is a pure no-op (matches GASP-hydration opt-in).
4252 let Some(fetcher) = self.ancestor_fetcher.clone() else {
4253 return Ok(summary);
4254 };
4255
4256 let candidates = self
4257 .storage
4258 .find_transactions_for_proof_check(limit, min_age_secs)
4259 .await
4260 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4261 summary.scanned = candidates.len();
4262
4263 for cand in candidates {
4264 // Read the stored BEEF for the subject's OWN bump by streaming
4265 // folds (one element in hand, no `Beef` built: NL-6); skip
4266 // anything unparseable (don't error the whole pass on one bad row).
4267 let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4268 Ok(b) => b,
4269 Err(e) => {
4270 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4271 continue;
4272 }
4273 };
4274
4275 // Already structurally proven? The stored BEEF carries a merkle bump
4276 // for the target tx, but its backend `has_proof` flag may still be 0
4277 // — EITHER a legitimate GASP-synced proof written before overlay
4278 // migration 0010 (which defaulted every existing row to 0), OR an
4279 // admit-time bump that was NEVER SPV-verified (`/submit` skips SPV on
4280 // historical topics) or is outright forged. Serve-time BEEF trimming
4281 // trusts the `has_proof` flag, so latching one on STRUCTURE ALONE
4282 // would let a forged bump be trimmed on (#192/#193 MEDIUM). Re-verify
4283 // the STORED bump against chaintracks (via the fetcher — its header
4284 // source is the only arbiter of a merkle root). Genuine → latch
4285 // (fast, no re-fetch). Otherwise DON'T trust it: fall through to the
4286 // fetch+stitch path below, which overwrites the bump with a
4287 // chaintracks-verified one (or leaves the row proofless for retry).
4288 // Fail-closed throughout.
4289 // the tx's OWN bump (its `bump_index`), never `find_bump`
4290 // (the FIRST bump containing the txid — the stale one after a
4291 // same-height reorg; review HIGH-1).
4292 if let Some(bump_hex) = own_bump
4293 .as_ref()
4294 .map(bsv_rs::transaction::MerklePath::to_hex)
4295 {
4296 if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4297 // Idempotent + best-effort: a failure here is logged, not
4298 // fatal — the row simply lingers one more tick.
4299 // bsv-low M19 R2 round 3 (review MED-2): latch WITH the
4300 // anchor height (from the tx's OWN verified bump) so the
4301 // revalidation sweep's transactions leg can window this
4302 // row — `mark_transaction_proven` alone left every
4303 // fast-path row anchorless, in no window forever.
4304 let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4305 .ok()
4306 .map(|mp| u64::from(mp.block_height));
4307 if let Err(e) = self
4308 .storage
4309 .mark_transaction_proven_at(&cand.txid, height)
4310 .await
4311 {
4312 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4313 } else {
4314 summary.already_proven += 1;
4315 }
4316 continue;
4317 }
4318 warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4319 }
4320 // Unverifiable structural bump → treat as proofless (fall through).
4321 summary.proofless += 1;
4322
4323 // Fetch this tx's own VERIFIED bump from chain — PROOF ONLY (the raw
4324 // is already in the stored BEEF, so no redundant raw fetch; #192/#193
4325 // FIX 2). A tx with no verified proof yet (still unconfirmed, or an
4326 // unverifiable/forged courier response) is skipped, not errored.
4327 let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4328 summary.still_unconfirmed += 1;
4329 continue;
4330 };
4331
4332 // Pull the block height out of the BUMP so the output rows update too.
4333 let block_height =
4334 beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4335 .ok()
4336 .map(|mp| mp.block_height);
4337
4338 match self
4339 .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4340 .await
4341 {
4342 Ok(()) => summary.completed += 1,
4343 Err(e) => {
4344 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4345 summary.stitch_failed += 1;
4346 }
4347 }
4348 }
4349
4350 info!(
4351 scanned = summary.scanned,
4352 proofless = summary.proofless,
4353 completed = summary.completed,
4354 still_unconfirmed = summary.still_unconfirmed,
4355 fetch_failed = summary.fetch_failed,
4356 stitch_failed = summary.stitch_failed,
4357 already_proven = summary.already_proven,
4358 "[PROOF COMPLETION] pass complete"
4359 );
4360
4361 Ok(summary)
4362 }
4363
4364 /// Recursively update merkle paths in a transaction's input tree.
4365 ///
4366 /// If the transaction's id matches txid, set its merkle_path.
4367 /// Otherwise, recurse into sourceTransactions of each input.
4368 /// Stitch a verified BUMP for `txid` into a STORED BEEF, preserving every
4369 /// transaction and every other proof in it (#284).
4370 ///
4371 /// The old path round-tripped the stored bytes through
4372 /// `Transaction::from_beef(bytes, None)` -> `to_beef(true)`, and
4373 /// `from_beef(_, None)` picks **`txs.last()`** — it ignores the atomic
4374 /// subject pointer entirely. Whenever the subject was not the last tx in
4375 /// wire order, a PARENT was picked, `to_beef(true)` then serialized that
4376 /// parent's world, and the rewrite dropped the subject transaction from its
4377 /// own stored BEEF — observed live on every pf head-spend (list / reprice /
4378 /// delist / buy) the moment it mined, breaking the NEXT spend on that name
4379 /// (zanaadu#284). `update_input_proofs`' "already has a proof — update it"
4380 /// arm could even stamp the child's bump onto that mis-picked parent.
4381 /// This is the same destroy-the-stored-BEEF class the #4 fix note above
4382 /// (`hydrate_utxo_history`) already documents for lookup hydration.
4383 ///
4384 /// Operating on [`Beef`] directly has none of those failure modes:
4385 /// `merge_bump` dedupes against existing bumps by (height, root) and
4386 /// assigns the bump ONLY to transactions that appear as flagged txid
4387 /// leaves, so stitching tx X's proof can never mislabel tx Y, and the tx
4388 /// set is never rebuilt at all.
4389 ///
4390 /// Returns `None` when the bytes do not parse or `txid` is not in this
4391 /// BEEF (nothing to stitch — e.g. a consuming row whose ancestry got
4392 /// trimmed), so callers skip the write instead of writing garbage.
4393 ///
4394 /// bsv-low M19 R2 round 3 (review HIGH-1): a same-height DIFFERENT-root
4395 /// proof (the 2026-09-07 reorg shape) does NOT combine in `merge_bump`
4396 /// (the roots differ) — it is pushed as a SECOND bump, and
4397 /// `update_bump_indices` assigns it only to txs whose `bump_index` is
4398 /// `None`, so the reorged subject keeps its index on the STALE bump.
4399 /// Left alone, `has_proof`/`proofHeight` and every `find_txid(txid)
4400 /// .bump_index()` reader then keep pointing at the orphan bump, and the
4401 /// row refutes again next sweep pass forever. So after the merge, FORCE
4402 /// the subject onto the bump that actually proves it, then drop any bump
4403 /// no transaction references (the orphan's, now unreferenced).
4404 pub fn stitch_proof_into_stored_beef(
4405 stored: &[u8],
4406 txid: &str,
4407 proof: &bsv_rs::transaction::MerklePath,
4408 ) -> Option<Vec<u8>> {
4409 let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4410 beef.find_txid(txid)?;
4411 let i = beef.merge_bump(proof.clone());
4412 // Does the merged bump actually prove `txid` (a flagged txid leaf)?
4413 // (It always does for a completion proof; the guard keeps a stray
4414 // caller from mis-anchoring a subject onto an ancestor's bump.)
4415 let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4416 b.path.first().is_some_and(|leaves| {
4417 leaves
4418 .iter()
4419 .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4420 })
4421 });
4422 if proves_subject {
4423 if let Some(tx) = beef.find_txid_mut(txid) {
4424 tx.set_bump_index(Some(i));
4425 }
4426 }
4427 Self::gc_unreferenced_bumps(&mut beef);
4428 Some(beef.to_binary())
4429 }
4430
4431 /// Drop every BUMP no transaction references and re-index the survivors
4432 /// (bsv-low M19 R2 round 3, review HIGH-1): after the subject is moved
4433 /// off an orphan bump, that bump is usually dead weight the next reader
4434 /// would still `find_bump`. A bump still referenced by ANOTHER tx (a
4435 /// sibling mined in the same block) is kept.
4436 fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4437 let referenced: std::collections::HashSet<usize> = beef
4438 .txs
4439 .iter()
4440 .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4441 .collect();
4442 if referenced.len() == beef.bumps.len() {
4443 return;
4444 }
4445 let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4446 let mut kept = Vec::with_capacity(referenced.len());
4447 for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4448 if referenced.contains(&old) {
4449 remap[old] = Some(kept.len());
4450 kept.push(bump);
4451 }
4452 }
4453 beef.bumps = kept;
4454 for tx in &mut beef.txs {
4455 if let Some(old) = tx.bump_index() {
4456 tx.set_bump_index(remap[old]);
4457 }
4458 }
4459 }
4460
4461 // ========================================================================
4462 // Advertisement sync
4463 // ========================================================================
4464
4465 /// Sync SHIP/SLAP advertisements with configured managers and services.
4466 ///
4467 /// Creates missing advertisements and revokes stale ones. Returns a
4468 /// [`SyncAdvertisementsReport`] so a create/submit failure is VISIBLE to
4469 /// the caller (bsv-low #320 defect 3a): this method used to swallow both
4470 /// into `error!` and return `Ok(())`, so `/admin/syncAdvertisements`
4471 /// reported `success` while zero advertisements were admitted locally —
4472 /// a caller could not tell a converged no-op from a silent failure, and
4473 /// every cycle re-created the full ad set.
4474 pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4475 let mut report = SyncAdvertisementsReport::default();
4476
4477 let Some(advertiser) = &self.advertiser else {
4478 return Ok(report); // No advertiser configured
4479 };
4480
4481 let hosting_url = match &self.config.hosting_url {
4482 Some(url) if !url.is_empty() => url.clone(),
4483 _ => return Ok(report), // No hosting URL
4484 };
4485
4486 // Get configured topics and services
4487 let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4488 let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4489
4490 if self.config.suppress_default_sync_advertisements {
4491 configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4492 configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4493 }
4494
4495 // Fetch current advertisements. A read failure REFUSES creation
4496 // (#320 M2): swallowing it into "current ads = none" made a
4497 // transient D1/storage fault re-create — and re-pay for — the
4498 // entire advertisement set, reported as a legitimate `to_create`.
4499 let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4500 Ok(ads) => ads,
4501 Err(e) => {
4502 error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4503 report.lookup_error = Some(format!("ship: {e}"));
4504 return Ok(report);
4505 }
4506 };
4507 let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4508 Ok(ads) => ads,
4509 Err(e) => {
4510 error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4511 report.lookup_error = Some(format!("slap: {e}"));
4512 return Ok(report);
4513 }
4514 };
4515
4516 // Determine what to create
4517 let ships_to_create: Vec<AdvertisementData> = configured_topics
4518 .iter()
4519 .filter(|topic| {
4520 !current_ship
4521 .iter()
4522 .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4523 })
4524 .map(|topic| AdvertisementData {
4525 protocol: Protocol::Ship,
4526 topic_or_service_name: topic.clone(),
4527 })
4528 .collect();
4529
4530 let slaps_to_create: Vec<AdvertisementData> = configured_services
4531 .iter()
4532 .filter(|service| {
4533 !current_slap
4534 .iter()
4535 .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4536 })
4537 .map(|service| AdvertisementData {
4538 protocol: Protocol::Slap,
4539 topic_or_service_name: service.clone(),
4540 })
4541 .collect();
4542
4543 // Determine what to revoke
4544 let ships_to_revoke: Vec<Advertisement> = current_ship
4545 .into_iter()
4546 .filter(|a| !configured_topics.contains(&a.topic_or_service))
4547 .collect();
4548
4549 let slaps_to_revoke: Vec<Advertisement> = current_slap
4550 .into_iter()
4551 .filter(|a| !configured_services.contains(&a.topic_or_service))
4552 .collect();
4553
4554 // Create new advertisements
4555 let mut all_to_create = ships_to_create;
4556 all_to_create.extend(slaps_to_create);
4557 report.to_create = all_to_create.len();
4558 if !all_to_create.is_empty() {
4559 match advertiser.create_advertisements(&all_to_create).await {
4560 Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4561 Ok(steak) => {
4562 for (topic, instructions) in &steak {
4563 report
4564 .admitted
4565 .insert(topic.clone(), instructions.outputs_to_admit.len());
4566 }
4567 }
4568 Err(e) => {
4569 error!("Failed to submit new advertisements: {e}");
4570 report.submit_error = Some(e.to_string());
4571 }
4572 },
4573 Err(e) => {
4574 error!("Failed to create advertisements: {e}");
4575 report.create_error = Some(e.to_string());
4576 }
4577 }
4578 }
4579
4580 // Revoke stale advertisements
4581 let mut all_to_revoke = ships_to_revoke;
4582 all_to_revoke.extend(slaps_to_revoke);
4583 report.to_revoke = all_to_revoke.len();
4584 if !all_to_revoke.is_empty() {
4585 match advertiser.revoke_advertisements(&all_to_revoke).await {
4586 // An advertiser that declines to build a revocation (e.g. the
4587 // CF advertiser's documented v1 no-op) returns an EMPTY
4588 // TaggedBEEF — submitting it would only manufacture a parse
4589 // error. Skip, but say so in the report (#320 L2): the stale
4590 // ads stay on-chain until they age out.
4591 Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4592 report.revoke_skipped = all_to_revoke.len();
4593 }
4594 Ok(tagged_beef) => {
4595 if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4596 error!("Failed to submit revocation: {e}");
4597 report.revoke_submit_error = Some(e.to_string());
4598 }
4599 }
4600 Err(e) => {
4601 error!("Failed to revoke advertisements: {e}");
4602 report.revoke_error = Some(e.to_string());
4603 }
4604 }
4605 }
4606
4607 Ok(report)
4608 }
4609
4610 // ========================================================================
4611 // Deep deletion
4612 // ========================================================================
4613
4614 /// Recursively delete a UTXO and all stale consumed inputs.
4615 ///
4616 /// Only deletes if the output has no remaining consumers (consumedBy is empty).
4617 /// Then recurses into outputsConsumed, removing the deleted output from their
4618 /// consumedBy lists and deleting them if they become unreferenced.
4619 #[allow(dead_code)] // Used by submit() once BEEF parsing is wired up
4620 ///
4621 /// `bound` is a finalize submit's [`CallBound`] over each call here
4622 /// (bsv-low #559); `None` everywhere else.
4623 fn delete_utxo_deep<'a>(
4624 &'a self,
4625 output: &'a Output,
4626 bound: Option<&'a CallBound>,
4627 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4628 Box::pin(async move {
4629 // Only delete if nothing consumes this output
4630 if output.consumed_by.is_empty() {
4631 stored(
4632 bound,
4633 self.storage
4634 .delete_output(&output.txid, output.output_index, &output.topic),
4635 )
4636 .await
4637 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4638
4639 // Notify lookup services. A hook error is dropped, not
4640 // reported (parity: the reference catches it, best effort).
4641 for ls in self.lookup_services.values() {
4642 let _ = hooked(
4643 bound,
4644 ls.output_no_longer_retained_in_history(
4645 &output.txid,
4646 output.output_index,
4647 &output.topic,
4648 ),
4649 )
4650 .await;
4651 }
4652 }
4653
4654 // Recurse into consumed outputs
4655 for consumed in &output.outputs_consumed {
4656 if let Ok(Some(mut stale_output)) = stored(
4657 bound,
4658 self.storage.find_output(
4659 &consumed.txid,
4660 consumed.output_index,
4661 Some(&output.topic),
4662 None,
4663 false,
4664 ),
4665 )
4666 .await
4667 {
4668 // Remove the deleted output from consumedBy
4669 stale_output.consumed_by.retain(|c| {
4670 !(c.txid == output.txid && c.output_index == output.output_index)
4671 });
4672
4673 stored(
4674 bound,
4675 self.storage.update_consumed_by(
4676 &consumed.txid,
4677 consumed.output_index,
4678 &output.topic,
4679 &stale_output.consumed_by,
4680 ),
4681 )
4682 .await
4683 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4684
4685 // Recurse
4686 let _ = self.delete_utxo_deep(&stale_output, bound).await;
4687 }
4688 }
4689
4690 Ok(())
4691 }) // Box::pin
4692 }
4693
4694 // ========================================================================
4695 // Eviction
4696 // ========================================================================
4697
4698 /// Evict a specific outpoint from the overlay.
4699 ///
4700 /// 1. Deletes the output from storage for the given topic.
4701 /// 2. Notifies all lookup services via `output_evicted()`.
4702 ///
4703 /// If no topic is specified, finds all outputs for the txid and evicts the
4704 /// matching outputIndex across every topic.
4705 ///
4706 /// Matches TS OverlayExpress `/admin/evictOutpoint` (lines 974-1004).
4707 /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): RE-NOTIFY every lookup service
4708 /// of the outputs the engine ALREADY admitted for (`txid`, `topic`).
4709 ///
4710 /// Phase-1 dedup means a (txid, topic) once recorded in
4711 /// `applied_transactions` never re-enters through `/submit`, so a lookup
4712 /// service that missed — or mis-parsed — its admit notification could
4713 /// never be told again (the loop-2 subject trap: the pot index's admit
4714 /// hook shape-checked a wire-last hop and wrote no record while the engine
4715 /// had admitted the pot). This replays `output_admitted_by_topic` from the
4716 /// engine's OWN stored outputs (with their BEEF) and the subject-NAMED body
4717 /// exactly as `submit_with_report` sends it. It validates nothing anew
4718 /// (the topic manager judged these outputs at admit), writes none of the
4719 /// engine's tables, and is idempotent for every lookup service whose
4720 /// writes are (the pot index's `store_record` is insert-if-absent for the
4721 /// spend fields — re-admission never regresses spend state).
4722 /// `outputs == 0` means the engine never admitted this txid on this topic:
4723 /// nothing to re-notify — the caller re-SUBMITS instead.
4724 pub async fn renotify_admitted(
4725 &self,
4726 txid: &str,
4727 topic: &str,
4728 ) -> Result<RenotifyReport, EngineError> {
4729 let txid = txid.to_ascii_lowercase();
4730 let outputs = self
4731 .storage
4732 .find_outputs_for_transaction(&txid, true)
4733 .await
4734 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4735 let mut report = RenotifyReport::default();
4736 for output in outputs.iter().filter(|o| o.topic == topic) {
4737 report.outputs += 1;
4738 report.vouts.push(output.output_index);
4739 // the body every WholeTx service receives NAMES the subject (BRC-95
4740 // atomic prefix) — the same rule as the live submit path
4741 let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4742 beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4743 .ok()
4744 .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4745 .unwrap_or_else(|| beef.clone())
4746 });
4747 for (name, ls) in &self.lookup_services {
4748 let payload = match ls.admission_mode() {
4749 AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4750 txid: txid.clone(),
4751 output_index: output.output_index,
4752 topic: topic.to_string(),
4753 satoshis: output.satoshis,
4754 locking_script: output.output_script.clone(),
4755 off_chain_values: None,
4756 },
4757 AdmissionMode::WholeTx => {
4758 let Some(atomic_beef) = subject_named.as_ref() else {
4759 report.faults.push(format!(
4760 "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4761 output.output_index
4762 ));
4763 continue;
4764 };
4765 OutputAdmittedByTopic::WholeTx {
4766 atomic_beef: atomic_beef.clone(),
4767 output_index: output.output_index,
4768 topic: topic.to_string(),
4769 off_chain_values: None,
4770 }
4771 }
4772 };
4773 match ls.output_admitted_by_topic(&payload).await {
4774 Ok(()) => report.notified += 1,
4775 Err(e) => report.faults.push(format!("{name}: {e}")),
4776 }
4777 }
4778 }
4779 Ok(report)
4780 }
4781
4782 /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): FORGET a PHANTOM applied row —
4783 /// (txid, topic) recorded in `applied_transactions` while the topic holds
4784 /// NO output of that txid (the engine judged the tx and admitted nothing:
4785 /// the loop-2 JOIN `f9e85aab…` under the pre-D5 subject rule). Such a row
4786 /// does nothing but block a correct re-submit through the Phase-1 dedup
4787 /// (`/admin/readmit` on beta answered `deduped: ["tm_pot_beta"]` and the
4788 /// pot stayed `known:false`). A row with ANY stored output on the topic is
4789 /// a real admission and is never touched (returns false, as does a missing
4790 /// row). The forgotten bytes are then re-VALIDATED by the topic manager
4791 /// like any first submit — nothing is admitted here.
4792 pub async fn forget_phantom_applied(
4793 &self,
4794 txid: &str,
4795 topic: &str,
4796 ) -> Result<bool, EngineError> {
4797 let txid = txid.to_ascii_lowercase();
4798 let outputs = self
4799 .storage
4800 .find_outputs_for_transaction(&txid, false)
4801 .await
4802 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4803 if outputs.iter().any(|o| o.topic == topic) {
4804 return Ok(false);
4805 }
4806 let rec = AppliedTransaction {
4807 txid: txid.clone(),
4808 topic: topic.to_string(),
4809 };
4810 let exists = self
4811 .storage
4812 .does_applied_transaction_exist(&rec)
4813 .await
4814 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4815 if !exists {
4816 return Ok(false);
4817 }
4818 self.storage
4819 .delete_applied_transaction(&rec)
4820 .await
4821 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4822 Ok(true)
4823 }
4824
4825 pub async fn evict_output(
4826 &self,
4827 txid: &str,
4828 output_index: u32,
4829 topic: Option<&str>,
4830 ) -> Result<(), EngineError> {
4831 if let Some(topic) = topic {
4832 // Delete from storage
4833 self.storage
4834 .delete_output(txid, output_index, topic)
4835 .await
4836 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4837
4838 // Notify all lookup services
4839 for ls in self.lookup_services.values() {
4840 let _ = ls.output_evicted(txid, output_index).await;
4841 }
4842 } else {
4843 // No topic specified — find all outputs for this txid and evict matching ones
4844 let outputs = self
4845 .storage
4846 .find_outputs_for_transaction(txid, false)
4847 .await
4848 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4849
4850 for output in &outputs {
4851 if output.output_index == output_index {
4852 self.storage
4853 .delete_output(txid, output_index, &output.topic)
4854 .await
4855 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4856 }
4857 }
4858
4859 // Notify all lookup services once
4860 for ls in self.lookup_services.values() {
4861 let _ = ls.output_evicted(txid, output_index).await;
4862 }
4863 }
4864
4865 Ok(())
4866 }
4867
4868 // ========================================================================
4869 // GASP sync
4870 // ========================================================================
4871
4872 /// Start GASP synchronization with peers for all configured topics.
4873 ///
4874 /// For each topic in `sync_configuration`:
4875 /// - `SyncTarget::Ship` — discovers peers via local `ls_ship` lookup, parses
4876 /// SHIP advertisement scripts to extract domain URLs.
4877 /// - `SyncTarget::Peers(urls)` — uses the provided peer URLs directly.
4878 /// - `SyncTarget::Disabled` — skips the topic.
4879 ///
4880 /// Filters out our own `hosting_url` to avoid self-sync.
4881 ///
4882 /// Returns a `GASPSyncResult` summarizing discovered peers per topic.
4883 ///
4884 /// Discovers peers for each configured topic and runs GASP sync with each.
4885 ///
4886 /// When a `GASPRemoteFactory` is set (via `set_gasp_remote_factory`), this
4887 /// method creates `OverlayGASPStorage` + `GASPRemote` instances per
4888 /// (topic, peer) pair and runs `GASPSync::sync()` to exchange UTXOs.
4889 ///
4890 /// Without a factory, peer discovery still runs but no actual sync occurs
4891 /// (backwards-compatible with the previous discovery-only behavior).
4892 pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
4893 use crate::gasp::{GASPSync, DEFAULT_GASP_SYNC_LIMIT};
4894 use crate::gasp_overlay::OverlayGASPStorage;
4895
4896 if self.config.sync_configuration.is_empty() {
4897 info!("[GASP SYNC] No sync configuration — nothing to sync");
4898 return Ok(GASPSyncResult {
4899 topics_synced: HashMap::new(),
4900 });
4901 }
4902
4903 let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
4904
4905 for (topic, target) in sync_order(&self.config.sync_configuration) {
4906 // A CONFIGURED topic's peers are reserved part of a storage's
4907 // deferred-graph ceiling (bsv-low #555, the delta-2 fold's D2-M2).
4908 let configured_peers = matches!(target, SyncTarget::Peers(_));
4909 let (peers, sync_type) = match target {
4910 SyncTarget::Disabled => {
4911 info!("[GASP SYNC] Topic {topic} is disabled — skipping");
4912 continue;
4913 }
4914 SyncTarget::Ship => {
4915 info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
4916 let peers = self.discover_ship_peers(topic).await;
4917 info!(
4918 "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
4919 peers.len()
4920 );
4921 (peers, "ship".to_string())
4922 }
4923 SyncTarget::Peers(peer_urls) => {
4924 info!(
4925 "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
4926 peer_urls.len()
4927 );
4928 let peers: Vec<String> = peer_urls
4929 .iter()
4930 .filter(|url| {
4931 if let Some(ref our_url) = self.config.hosting_url {
4932 url.trim_end_matches('/') != our_url.trim_end_matches('/')
4933 } else {
4934 true
4935 }
4936 })
4937 .cloned()
4938 .collect();
4939
4940 info!(
4941 "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
4942 peers.len()
4943 );
4944 (peers, "peers".to_string())
4945 }
4946 };
4947
4948 let mut errors = Vec::new();
4949 let mut pruned_inputs: u64 = 0;
4950 let mut discarded_graphs: u64 = 0;
4951 let mut anchor_walk_held_graphs: u64 = 0;
4952 let mut finalized_graphs: u64 = 0;
4953 let mut deadline_dropped_graphs: u64 = 0;
4954 let mut cursor_moves: Vec<CursorMove> = Vec::new();
4955 let mut deferred_graphs: u64 = 0;
4956 let mut resumed_graphs: u64 = 0;
4957 let mut converged_graphs: u64 = 0;
4958 let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
4959 let mut stalled_graphs: u64 = 0;
4960 let mut held_back_graphs: u64 = 0;
4961
4962 // If we have a remote factory, actually run GASP sync
4963 if let Some(ref factory) = self.gasp_remote_factory {
4964 for peer_url in &peers {
4965 // bsv-low#302 quarantine gate: a peer at
4966 // PEER_QUARANTINE_THRESHOLD consecutive failures is
4967 // SKIPPED (no attempt recorded — its last-attempt age
4968 // keeps growing) until the re-probe window opens. A
4969 // health-read FAULT treats the peer as healthy: broken
4970 // bookkeeping must never silence a live peer.
4971 //
4972 // The peer's health (its quarantine, its yieldless
4973 // streak) is keyed on its NORMALIZED ORIGIN (bsv-low
4974 // #555, the delta-2 fold's D2-M2): keyed on the URL, one
4975 // server was a fresh peer for every spelling it
4976 // advertised.
4977 let health_key = crate::gasp::peer_origin(peer_url);
4978 let health = self
4979 .storage
4980 .get_peer_sync_health(&health_key, topic)
4981 .await
4982 .unwrap_or_default();
4983 if crate::gasp::peer_sync_quarantined(&health) {
4984 warn!(
4985 "[GASP SYNC] Peer {peer_url} for {topic} QUARANTINED ({} consecutive failed syncs, last attempt {:?}s ago) — skipped until the {}s re-probe window (bsv-low#302)",
4986 health.consecutive_failures,
4987 health.secs_since_last_attempt,
4988 crate::gasp::PEER_QUARANTINE_REPROBE_SECS
4989 );
4990 continue;
4991 }
4992
4993 info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
4994
4995 // Get last interaction score for this (peer, topic) pair
4996 let last_interaction = self
4997 .storage
4998 .get_last_interaction(peer_url, topic)
4999 .await
5000 .unwrap_or(0);
5001
5002 // Create shared sink for finalized graphs
5003 let sink = crate::gasp_overlay::new_finalized_graph_sink();
5004
5005 // OPT-IN ancestor hydration: only when a fetcher is
5006 // configured do we enable chain-backed ancestor fallback +
5007 // strict-BEEF finalize. Default (None) = byte-identical to
5008 // today (tolerant BEEF, peer errors abandon the graph;
5009 // the one exception is the D8 prune of a manager-named
5010 // input the peer answers it does not hold, which exists
5011 // only WITHOUT a fetcher, see
5012 // `GASPSync::process_incoming_node`).
5013 let hydration_on = self.ancestor_fetcher.is_some();
5014
5015 // Create storage adapter and remote
5016 let mut gasp_storage =
5017 OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5018 .with_peer(peer_url.as_str())
5019 .with_configured_peer(configured_peers)
5020 .with_strict_beef(hydration_on)
5021 .with_script_verification(self.verify_scripts)
5022 .with_walk_budget(self.walk_budget);
5023 if let Some(manager) = self.managers.get(topic) {
5024 gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5025 }
5026 // bsv-low #551: the anchor check verifies each graph's
5027 // root against THIS engine's tracker, with this engine's
5028 // script switch, before finalize. No tracker is the
5029 // reference's 'scripts only'.
5030 if let Some(tracker) = self.chain_tracker.as_deref() {
5031 gasp_storage = gasp_storage.with_chain_tracker(tracker);
5032 }
5033 let gasp_remote = factory.create_remote(peer_url, topic);
5034
5035 let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5036 // Graphs of this peer handed to submit (bsv-low #552).
5037 let peer_finalized = std::cell::Cell::new(0u64);
5038 let mut sync = GASPSync::new(
5039 Box::new(gasp_storage),
5040 gasp_remote,
5041 last_interaction,
5042 &log_prefix,
5043 true, // unidirectional — overlay GASP is pull-only (submitNode throws); matches TS Engine.startGASPSync
5044 )
5045 .with_ancestor_fetcher(self.ancestor_fetcher.clone());
5046 // bsv-low #555: a graph past its own budget is deferred,
5047 // not dropped with the sync.
5048 if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5049 let (sleep, budget_ms) = (sleep.clone(), *budget_ms);
5050 let half_sleep = sleep.clone();
5051 sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5052 max_calls: *max_calls,
5053 max_bytes_fetched: self.graph_budget_limbs.0,
5054 max_nodes: self.graph_budget_limbs.1,
5055 deadline: Box::new(move || sleep(budget_ms)),
5056 half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5057 });
5058 }
5059 // bsv-low #552: under a budget each graph is submitted as
5060 // it finalizes, so the deadline cannot take it back. With
5061 // no budget nothing can drop the sync, and the graphs are
5062 // submitted after it as they always were.
5063 if self.peer_sync_budget.is_some() {
5064 sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5065 engine: self,
5066 sink: sink.clone(),
5067 peer_url,
5068 topic,
5069 submitted: &peer_finalized,
5070 }));
5071 }
5072
5073 // bsv-low#302: bound THIS peer's sync to the configured
5074 // budget. `None` from the race = the budget won and the
5075 // sync future was dropped mid-flight. What it finalized
5076 // before that is already submitted (the hook above); the
5077 // cursor then advances to `completed_cursor` only, see
5078 // the deadline arm below. The race is GUARDED: it never
5079 // drops the sync inside one transaction's finalize
5080 // submit (the lens fold's HIGH-1), it waits for that
5081 // transaction and the hook stops at the boundary.
5082 let sync_outcome = match &self.peer_sync_budget {
5083 Some((sleep, budget_ms)) => {
5084 crate::gasp::race_or_deadline_guarded(
5085 sync.sync(Some(DEFAULT_GASP_SYNC_LIMIT)),
5086 sleep(*budget_ms),
5087 &self.finalize_gate,
5088 )
5089 .await
5090 }
5091 None => Some(sync.sync(Some(DEFAULT_GASP_SYNC_LIMIT)).await),
5092 };
5093
5094 let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5095 // D8 decoy rule: branches pruned in this peer's walk. Not
5096 // an error and not a failed UTXO, so it is counted apart
5097 // from `errors` and never touches the outcome or cursor.
5098 // Summed over peers: one decoy seen through two peers
5099 // counts twice.
5100 pruned_inputs += sync.pruned_inputs();
5101 // bsv-low #551: graphs the anchor check refused. Counted
5102 // apart from `errors` for the same reason as a prune: a
5103 // refused graph is not a failed sync (the reference
5104 // discards it and carries on).
5105 discarded_graphs += sync.discarded_graphs();
5106 anchor_walk_held_graphs += sync.anchor_walk_held();
5107 // Whether the sync ran (to its end, or to the deadline)
5108 // and whether it moved the cursor (bsv-low #555, the
5109 // delta fold's D-M2).
5110 let ran = !matches!(sync_outcome, Some(Err(_)));
5111 let mut advanced_cursor = false;
5112 match sync_outcome {
5113 None => {
5114 let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5115 let msg = format!(
5116 "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5117 );
5118 warn!("[GASP SYNC] {msg}");
5119 errors.push(msg);
5120
5121 // bsv-low #552: the work COMPLETED before the
5122 // deadline is kept. Its graphs are already
5123 // submitted (the hook); the cursor moves past the
5124 // UTXOs that were completed and stops below the
5125 // one in flight, whose graph is lost whole and
5126 // walked again next tick. A graph the deadline
5127 // met while it was being SUBMITTED is not "in
5128 // flight" here: it left a prefix of whole
5129 // transactions, is counted finalized, and its
5130 // UTXO is below the cursor too.
5131 let in_flight = u64::from(sync.graph_in_flight());
5132 deadline_dropped_graphs += in_flight;
5133 // bsv-low #555: the walk in hand is saved, so the
5134 // next tick resumes it instead of walking it again.
5135 sync.defer_in_flight().await;
5136 let completed = sync.completed_cursor();
5137 if completed > last_interaction {
5138 match self
5139 .storage
5140 .update_last_interaction(peer_url, topic, completed)
5141 .await
5142 {
5143 Ok(()) => cursor_moves.push(CursorMove {
5144 peer: peer_url.clone(),
5145 from: last_interaction,
5146 to: completed,
5147 }),
5148 Err(e) => warn!(
5149 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5150 ),
5151 }
5152 }
5153 // A tick that finalized a graph or moved the
5154 // cursor reached a live peer: not a failed
5155 // attempt for the quarantine count. So did one
5156 // whose walk PROGRESSED (bsv-low #555: it
5157 // appended a node, or completed its graph). A
5158 // deferral that fetched nothing is no progress
5159 // (the lens fold's H1: a hung peer deferred with
5160 // 0 nodes on every tick and was never
5161 // quarantined).
5162 advanced_cursor = completed > last_interaction;
5163 outcome_success = peer_finalized.get() > 0
5164 || completed > last_interaction
5165 || sync.deferral_stats().progressed > 0;
5166 warn!(
5167 "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5168 peer_finalized.get(),
5169 completed.max(last_interaction)
5170 );
5171 }
5172 Some(Ok(())) => {
5173 // Submit finalized graphs to the Engine (under a
5174 // budget the hook already did, graph by graph,
5175 // and the sink is empty here).
5176 let landed = self
5177 .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5178 .await;
5179 // bsv-low #555, the lens fold's H1: a sync that
5180 // ran to its end with walks the per-graph budget
5181 // cut having FETCHED NOTHING, and nothing else
5182 // got done (no walk progressed, no graph
5183 // finalized, the cursor did not move), reached
5184 // a peer that does not answer: a FAILED attempt.
5185 // Before #555 such a peer held the sync to the
5186 // per-peer deadline, a failure; the per-graph
5187 // budget had made it an `Ok` sync. A sync whose
5188 // UTXOs failed in any other way is unchanged.
5189 let stats = sync.deferral_stats();
5190 if stats.stalled > 0
5191 && stats.progressed == 0
5192 && peer_finalized.get() == 0
5193 && !(landed && sync.last_interaction > last_interaction)
5194 {
5195 outcome_success = false;
5196 let msg = format!(
5197 "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5198 stats.stalled
5199 );
5200 warn!("[GASP SYNC] {msg}");
5201 errors.push(msg);
5202 }
5203
5204 // Advance the persisted cursor when it moved forward — i.e.
5205 // the peer reported UTXOs at a higher score than our last
5206 // `since` (the in-memory cursor only grows on SEEN scores,
5207 // so `>` already excludes the empty-bootstrap case where no
5208 // UTXOs were seen). Mirrors TS `if (gasp.lastInteraction >
5209 // lastInteraction)` — NOT gated on a new submission: a sync
5210 // that re-sees already-known UTXOs must still advance, else
5211 // every cron re-scans the same range forever (the cursor
5212 // stayed pinned at 0 in prod).
5213 //
5214 // NOT advanced when a finalize submit above did
5215 // not land (the lens fold's MEDIUM-1): the sink
5216 // does not say which UTXO that graph was, so the
5217 // whole cursor stays and the next sync is served
5218 // the range again (what did land is known and
5219 // skipped). Under a budget the hook fails that
5220 // one UTXO instead and the gap guard has already
5221 // capped `sync.last_interaction` below it.
5222 advanced_cursor = landed && sync.last_interaction > last_interaction;
5223 if !landed {
5224 warn!(
5225 "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5226 );
5227 } else if sync.last_interaction > last_interaction {
5228 match self
5229 .storage
5230 .update_last_interaction(peer_url, topic, sync.last_interaction)
5231 .await
5232 {
5233 Ok(()) => cursor_moves.push(CursorMove {
5234 peer: peer_url.clone(),
5235 from: last_interaction,
5236 to: sync.last_interaction,
5237 }),
5238 Err(e) => warn!(
5239 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5240 ),
5241 }
5242 }
5243 info!(
5244 "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5245 sync.last_interaction
5246 );
5247 }
5248 Some(Err(e)) => {
5249 let msg = format!("{peer_url}: {e}");
5250 warn!("[GASP SYNC] Sync failed: {msg}");
5251 errors.push(msg);
5252 }
5253 }
5254 // bsv-low #555, the delta fold's D-M2: "progress" (a node
5255 // appended) is the peer's word. A sync the peer served
5256 // work that finalized no graph and moved no cursor is
5257 // YIELDLESS; past PEER_YIELDLESS_SYNCS_ALLOWED of them in
5258 // a row each is a FAILED attempt, so a peer that serves
5259 // one fresh root a tick and hangs on its input is
5260 // quarantined. A quiet sync (nothing served) leaves the
5261 // count; a fault of the count is fail-safe (no failure).
5262 // Only with a per-graph budget: without one nothing is
5263 // deferred and #302's rule is unchanged.
5264 if ran && self.graph_budget.is_some() {
5265 let yielded = peer_finalized.get() > 0 || advanced_cursor;
5266 if yielded || sync.graphs_attempted() > 0 {
5267 match self
5268 .storage
5269 .record_peer_sync_yield(&health_key, topic, yielded)
5270 .await
5271 {
5272 Ok(streak)
5273 if !yielded
5274 && crate::gasp::yieldless_sync_failed(&streak)
5275 && outcome_success =>
5276 {
5277 outcome_success = false;
5278 let msg = format!(
5279 "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5280 streak.yieldless_syncs,
5281 streak.secs_since_first.unwrap_or(0),
5282 crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5283 crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5284 );
5285 warn!("[GASP SYNC] {msg}");
5286 errors.push(msg);
5287 }
5288 Ok(_) => {}
5289 Err(e) => warn!(
5290 "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5291 ),
5292 }
5293 }
5294 }
5295 finalized_graphs += peer_finalized.get();
5296 let deferral = sync.deferral_stats();
5297 deferred_graphs += deferral.deferred;
5298 resumed_graphs += deferral.resumed;
5299 converged_graphs += deferral.converged;
5300 stalled_graphs += deferral.stalled;
5301 held_back_graphs += deferral.held_back;
5302 dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5303 peer: peer_url.clone(),
5304 outpoint: d.outpoint,
5305 reason: d.reason.as_str().to_string(),
5306 }));
5307
5308 // bsv-low#302: record the attempt outcome (success resets
5309 // the consecutive-failure count; timeout/error increments
5310 // it). Best-effort — a bookkeeping fault must never fail
5311 // the sync pass itself.
5312 //
5313 // bsv-low #555, the delta-4 fold's D4-L2: with a
5314 // per-graph budget, a peer that was at the quarantine
5315 // threshold when this sync began is re-admitted only by
5316 // a YIELD (a graph finalized, the cursor moved). A sync
5317 // that did not fail and yielded nothing (an empty
5318 // listing at its probe, a walk that only progressed) is
5319 // NOT RECORDED: the failures stay, and so does the
5320 // last-attempt stamp, so the peer is attended again on
5321 // the next tick and its next failure re-arms the
5322 // quarantine at once. Recorded as a success, a hostile
5323 // peer's empty listing at each probe reset its failures
5324 // and (the decay being for a peer that is not failed)
5325 // its yieldless streak: 100 of 200 ticks. A health read
5326 // that faulted read 0 failures: the old rule, fail-safe.
5327 if outcome_success
5328 && !(peer_finalized.get() > 0 || advanced_cursor)
5329 && self.graph_budget.is_some()
5330 && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5331 {
5332 info!(
5333 "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5334 health.consecutive_failures
5335 );
5336 } else if let Err(e) = self
5337 .storage
5338 .record_peer_sync_outcome(&health_key, topic, outcome_success)
5339 .await
5340 {
5341 warn!(
5342 "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5343 );
5344 }
5345 }
5346 }
5347
5348 topics_synced.insert(
5349 topic.clone(),
5350 TopicSyncResult {
5351 peers,
5352 sync_type,
5353 errors,
5354 pruned_inputs,
5355 discarded_graphs,
5356 anchor_walk_held_graphs,
5357 finalized_graphs,
5358 deadline_dropped_graphs,
5359 cursor_moves,
5360 deferred_graphs,
5361 resumed_graphs,
5362 converged_graphs,
5363 dropped_graphs,
5364 stalled_graphs,
5365 held_back_graphs,
5366 },
5367 );
5368 }
5369
5370 info!(
5371 "[GASP SYNC] Peer discovery complete for {} topic(s)",
5372 topics_synced.len()
5373 );
5374
5375 Ok(GASPSyncResult { topics_synced })
5376 }
5377
5378 /// Drain `sink` and submit every finalized graph in it, ancestors first
5379 /// (`historical-tx-no-spv`: the anchor check already verified them).
5380 /// Each graph is added to `submitted` as its first submit starts. Returns
5381 /// whether EVERY transaction of every graph landed.
5382 ///
5383 /// Each graph here passed the anchor check: its root verified and the
5384 /// replay admitted it, so these submits rest on a checked premise and are
5385 /// not expected to fail. If one does not LAND, the REST OF THAT GRAPH is
5386 /// not submitted: the reference's `finalizeGraph` awaits each submit in
5387 /// order and its throw ends the loop (the BEEFs already submitted stay,
5388 /// it has no rollback either). A later BEEF would otherwise be judged
5389 /// without the coin its ancestor failed to leave, admit nothing and be
5390 /// recorded as applied, a dupe for good. "Landed" is read from the
5391 /// durability report, not from `Ok`: `submit` answers `Ok` when a storage
5392 /// write faulted and when the topic manager failed, and in both it
5393 /// records no applied row. A transaction landed when its topic is applied
5394 /// or was already applied (a dupe). Other graphs carry on, and the caller
5395 /// keeps the cursor from passing a graph that did not land.
5396 ///
5397 /// Each transaction's submit is one write section of the engine's gate
5398 /// ([`Engine::finalize_submit_gate`]) and the gate is asked between two:
5399 /// a deadline that is due stops the sequence there (this future is
5400 /// dropped at that await, between two whole transactions).
5401 async fn submit_finalized_graphs(
5402 &self,
5403 sink: &crate::gasp_overlay::FinalizedGraphSink,
5404 peer_url: &str,
5405 topic: &str,
5406 submitted: &std::cell::Cell<u64>,
5407 ) -> bool {
5408 let finalized: Vec<_> = sink
5409 .lock()
5410 .unwrap_or_else(std::sync::PoisonError::into_inner)
5411 .drain(..)
5412 .collect();
5413
5414 let mut all_landed = true;
5415 for graph in &finalized {
5416 for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5417 self.finalize_gate.stop_if_due().await;
5418 if position == 0 {
5419 submitted.set(submitted.get() + 1);
5420 }
5421 let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5422 // No deadline drops a write section, and the section does
5423 // not drop ITSELF either (bsv-low #559, the delta lens's
5424 // DELTA-3 and the lens fold of 2026-10-07, F2,
5425 // `set_finalize_submit_budget`): the submit always runs to
5426 // its end. What is bounded is each storage call and hook
5427 // inside it ([`CallBound`]): one that never answers is that
5428 // call's FAULT, the submit handles it as any fault (nothing
5429 // of the transaction held, or all of it), the transaction
5430 // did not land, its UTXO fails and the cursor stays.
5431 let outcome = {
5432 let _writing = self.finalize_gate.write_section();
5433 let bound = self
5434 .finalize_submit_budget
5435 .as_ref()
5436 .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5437 self.submit_bounded(
5438 &tagged,
5439 SubmitMode::HistoricalTxNoSpv,
5440 bound.as_ref(),
5441 true,
5442 WalkBreachPolicy::NotNow,
5443 )
5444 .await
5445 };
5446 let not_landed = match outcome {
5447 Ok((_, report)) => {
5448 let landed = report.is_durable()
5449 && (report.applied_topics.contains(&graph.topic)
5450 || report.deduped_topics.contains(&graph.topic));
5451 (!landed).then(|| {
5452 if report.is_durable() {
5453 "the topic manager failed on it".to_string()
5454 } else {
5455 format!("not durable: {}", report.summary())
5456 }
5457 })
5458 }
5459 Err(e) => Some(e.to_string()),
5460 };
5461 if let Some(why) = not_landed {
5462 warn!(
5463 "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
5464 graph.topic
5465 );
5466 all_landed = false;
5467 break;
5468 }
5469 }
5470 }
5471
5472 if !finalized.is_empty() {
5473 info!(
5474 "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
5475 finalized.len()
5476 );
5477 }
5478 all_landed
5479 }
5480
5481 /// Discover peer overlay nodes for a topic via SHIP lookup.
5482 ///
5483 /// Queries the local `ls_ship` lookup service for SHIP advertisement records
5484 /// matching the given topic, then parses each record's PushDrop locking script
5485 /// to extract the advertised domain URL. Filters out our own hosting URL.
5486 async fn discover_ship_peers(&self, topic: &str) -> Vec<String> {
5487 let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
5488 warn!("[GASP SYNC] No ls_ship lookup service registered — cannot discover peers");
5489 return Vec::new();
5490 };
5491
5492 let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
5493
5494 let refs = match ship_ls.lookup(&question).await {
5495 Ok(LookupResult::OutputList(refs)) => refs,
5496 Ok(LookupResult::Answer(_)) => {
5497 error!(
5498 "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
5499 LookupAnswer; expected OutputList. Skipping."
5500 );
5501 return Vec::new();
5502 }
5503 Err(e) => {
5504 error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
5505 return Vec::new();
5506 }
5507 };
5508
5509 let mut domains = HashSet::new();
5510 for reference in &refs {
5511 if let Ok(Some(output)) = self
5512 .storage
5513 .find_output(&reference.txid, reference.output_index, None, None, false)
5514 .await
5515 {
5516 if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
5517 // Every spelling of our own origin is ourselves (the
5518 // delta-2 fold's D2-M2: `https://us/?x` passed the
5519 // string compare).
5520 if let Some(ref our_url) = self.config.hosting_url {
5521 if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
5522 || crate::gasp::peer_origin(&domain)
5523 == crate::gasp::peer_origin(our_url)
5524 {
5525 continue;
5526 }
5527 }
5528 domains.insert(domain);
5529 }
5530 }
5531 }
5532
5533 // One peer per normalized origin (bsv-low #555, the delta-2 fold's
5534 // D2-M2, restored by the delta-4 fold's D4-M1): eight spellings of
5535 // one server are one peer, the one its health row is keyed by.
5536 crate::gasp::ship_peers_by_origin(domains)
5537 }
5538
5539 // ========================================================================
5540 // Helpers
5541 // ========================================================================
5542
5543 /// Get a reference to the storage backend.
5544 pub fn storage(&self) -> &dyn Storage {
5545 self.storage.as_ref()
5546 }
5547
5548 /// Get the engine configuration.
5549 pub fn config(&self) -> &EngineConfig {
5550 &self.config
5551 }
5552}
5553
5554/// The SPV check of [`Engine::submit`], as one function over a BORROWED
5555/// tracker: the reference's `tx.verify(chainTracker)` (roots against the
5556/// tracker, every unproven input's script executed, the value rule), its
5557/// `tx.verify('scripts only')` when `chain_tracker` is `None` (roots accepted
5558/// unchecked, scripts still run), and the pre-2026-09-08 structural check when
5559/// `verify_scripts` is `false` ([`Engine::set_script_verification`]'s escape
5560/// hatch).
5561///
5562/// There is ONE verifier. `Engine::submit` calls this with its own tracker,
5563/// switch and walk budget; the GASP anchor check
5564/// (`OverlayGASPStorage::validate_graph_anchor`, bsv-low #551) calls it with
5565/// the same three, handed over by `Engine::start_gasp_sync`.
5566///
5567/// `Ok(Some(_))` is a walk that COULD NOT RUN within `budget` (bsv-low #592):
5568/// not a verdict on the transaction. The caller goes on as it would without a
5569/// walk and says so.
5570pub(crate) async fn verify_spv_like_the_reference(
5571 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5572 verify_scripts: bool,
5573 beef_bytes: &[u8],
5574 subject_txid: &str,
5575 budget: DoorBudget,
5576) -> Result<Option<WalkCouldNotRun>, EngineError> {
5577 verify_spv_trusting(
5578 chain_tracker,
5579 verify_scripts,
5580 beef_bytes,
5581 subject_txid,
5582 &HashSet::new(),
5583 budget,
5584 )
5585 .await
5586}
5587
5588/// [`verify_spv_like_the_reference`] trusting the transactions an earlier
5589/// walk of the same submit passed over (a predecessor the door lands first,
5590/// the E1D delta fold, L4): the script walk neither checks nor descends
5591/// them. The structural escape hatch checks the whole BEEF it is given.
5592async fn verify_spv_trusting(
5593 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5594 verify_scripts: bool,
5595 beef_bytes: &[u8],
5596 subject_txid: &str,
5597 trusted: &HashSet<String>,
5598 budget: DoorBudget,
5599) -> Result<Option<WalkCouldNotRun>, EngineError> {
5600 if verify_scripts {
5601 Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
5602 } else {
5603 Engine::verify_spv_structurally(chain_tracker, beef_bytes)
5604 .await
5605 .map(|()| None)
5606 }
5607}
5608
5609/// The BUMP that proves each txid of `beef`: the first BUMP whose level 0
5610/// carries it, as `Beef::find_bump` picks, built in one pass over the BUMPs'
5611/// leaves (NL-6e; `find_bump` per transaction scans them all each time).
5612fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
5613 let mut proven: HashMap<&str, usize> = HashMap::new();
5614 for (at, bump) in beef.bumps.iter().enumerate() {
5615 for leaf in bump.path.first().into_iter().flatten() {
5616 if let Some(hash) = leaf.hash.as_deref() {
5617 proven.entry(hash).or_insert(at);
5618 }
5619 }
5620 }
5621 proven
5622}
5623
5624/// The transactions the SPV walk of `from` passes over in `beef`
5625/// ([`Engine::verify_beef_linear_with`]'s traversal, nothing checked): `from`
5626/// and every source the BEEF carries, a proven transaction included and not
5627/// descended. The E1D delta fold, L4: what a landing need not walk again.
5628fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
5629 let bodies: HashMap<String, &Transaction> = beef
5630 .txs
5631 .iter()
5632 .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
5633 .collect();
5634 let proven = proven_index(beef);
5635 let mut cover = HashSet::new();
5636 let mut queue = vec![from.to_string()];
5637 while let Some(txid) = queue.pop() {
5638 if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
5639 continue;
5640 }
5641 if let Some(tx) = bodies.get(&txid) {
5642 queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
5643 }
5644 }
5645 cover
5646}
5647
5648/// The engine's [`crate::gasp::FinalizedGraphHook`] (bsv-low #552): submit
5649/// what a peer's sync just finalized, before its next UTXO is asked for.
5650struct SubmitAsFinalized<'e> {
5651 engine: &'e Engine,
5652 sink: crate::gasp_overlay::FinalizedGraphSink,
5653 peer_url: &'e str,
5654 topic: &'e str,
5655 submitted: &'e std::cell::Cell<u64>,
5656}
5657
5658#[async_trait::async_trait(?Send)]
5659impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
5660 async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
5661 // A graph is counted as its first submit starts, and the deadline
5662 // cannot land inside a submit: a sync dropped in here has written at
5663 // least that transaction whole (an ancestors-first prefix).
5664 let landed = self
5665 .engine
5666 .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
5667 .await;
5668 if landed {
5669 Ok(())
5670 } else {
5671 // Fails this UTXO: the cursor stays below it and the next sync
5672 // asks for it again (the lens fold's MEDIUM-1).
5673 Err(crate::gasp::GASPError::StorageError(
5674 "a finalize submit did not land".to_string(),
5675 ))
5676 }
5677 }
5678}
5679
5680/// Result of a GASP sync operation.
5681///
5682/// Returned by `Engine::start_gasp_sync()` to summarize what happened.
5683#[derive(Debug, Clone, Serialize, Deserialize)]
5684pub struct GASPSyncResult {
5685 /// Per-topic sync results.
5686 pub topics_synced: HashMap<String, TopicSyncResult>,
5687}
5688
5689/// Sync result for a single topic.
5690#[derive(Debug, Clone, Serialize, Deserialize)]
5691pub struct TopicSyncResult {
5692 /// Peer URLs discovered or configured for this topic.
5693 pub peers: Vec<String>,
5694 /// How peers were determined: "ship" or "peers".
5695 pub sync_type: String,
5696 /// Any errors encountered during sync (peer URL -> error message): a
5697 /// peer whose sync failed or ran over its budget. A single UTXO whose
5698 /// graph ingest failed is NOT listed here: it is warned (`Error ingesting
5699 /// UTXO`), held back by the cursor gap guard and asked for again by the
5700 /// next sync.
5701 pub errors: Vec<String>,
5702 /// Manager-named inputs pruned because a peer answered that it does not
5703 /// hold them (the D8 decoy rule, `GASPSync::pruned_inputs`). Counted per
5704 /// distinct outpoint PER PEER per sync and summed over this topic's
5705 /// peers: one decoy seen through two peers reads 2. Not errors: the
5706 /// graphs completed without those branches.
5707 #[serde(default)]
5708 pub pruned_inputs: u64,
5709 /// Graphs the anchor check REFUSED and discarded whole (bsv-low #551,
5710 /// `GASPSync::discarded_graphs`): a root whose BEEF does not verify, or
5711 /// one the topic manager does not admit at the end of the replay. Summed
5712 /// over this topic's peers. Not errors, and nothing of them was admitted.
5713 #[serde(default)]
5714 pub discarded_graphs: u64,
5715 /// Graphs HELD because their anchor's script walk could not run within
5716 /// the engine's walk budget (bsv-low #592; the E592 lens fold, H1 and
5717 /// L3; `GASPSync::anchor_walk_held`), summed over this topic's peers.
5718 /// Nothing of them was finalized; each UTXO stays below its cursor until
5719 /// the peer serves its root proven. Not errors and not discarded.
5720 #[serde(default)]
5721 pub anchor_walk_held_graphs: u64,
5722 /// Graphs that passed the anchor check and were handed to submit, summed
5723 /// over this topic's peers (bsv-low #552). Under a per-peer budget a
5724 /// graph is counted, and submitted, the moment it finalizes, so the
5725 /// count includes those of a peer the deadline then dropped. A counted
5726 /// graph had its first transaction submitted whole (the deadline never
5727 /// lands inside a submit); one whose later submit did not land is still
5728 /// counted, and its UTXO stays below the cursor.
5729 /// A graph is counted as its first submit STARTS; a first submit that FAULTS
5730 /// still counts here, so this is not a delivered count (bsv-low #559).
5731 #[serde(default)]
5732 pub finalized_graphs: u64,
5733 /// Graphs that were mid-walk when a peer's sync was dropped at its
5734 /// budget (bsv-low #552): at most one per peer per sync. NOTHING of such
5735 /// a graph was admitted; the next sync walks it again. The same root
5736 /// counted tick after tick with no `finalized_graphs` and no
5737 /// `cursor_moves` is ONE graph whose walk outlasts the budget (with a
5738 /// per-graph budget it is also counted in `deferred_graphs` and resumed,
5739 /// bsv-low #555).
5740 #[serde(default)]
5741 pub deadline_dropped_graphs: u64,
5742 /// Every persisted cursor that moved in this sync (bsv-low #552), one
5743 /// entry per peer. After a completed sync `to` is the peer's highest
5744 /// score seen (less the gap guard); after a deadline it is
5745 /// `GASPSync::completed_cursor`: past completed UTXOs only.
5746 #[serde(default)]
5747 pub cursor_moves: Vec<CursorMove>,
5748 /// Graphs DEFERRED in this sync, summed over peers (bsv-low #555,
5749 /// `Engine::set_graph_budget`): a new record, or a resumed graph
5750 /// deferred again. Their UTXOs are held below the cursor.
5751 #[serde(default)]
5752 pub deferred_graphs: u64,
5753 /// Graphs RESUMED from a record in this sync.
5754 #[serde(default)]
5755 pub resumed_graphs: u64,
5756 /// Resumed graphs that completed and landed in this sync (their record
5757 /// deleted).
5758 #[serde(default)]
5759 pub converged_graphs: u64,
5760 /// Records deleted without converging, with their reason.
5761 #[serde(default)]
5762 pub dropped_graphs: Vec<DroppedDeferral>,
5763 /// Walks the per-graph budget (or the per-peer deadline) cut having
5764 /// fetched NOTHING in their pass (bsv-low #555, the lens fold's H1). A
5765 /// peer's sync whose only outcome was these is a failed attempt for the
5766 /// quarantine count.
5767 #[serde(default)]
5768 pub stalled_graphs: u64,
5769 /// Records NOT resumed in this sync: the resumes of a peer's pass share
5770 /// ONE per-graph budget and had spent it (the lens fold's M1). Held for
5771 /// the next sync, untouched.
5772 #[serde(default)]
5773 pub held_back_graphs: u64,
5774}
5775
5776/// A deferred graph's record deleted without converging (bsv-low #555,
5777/// [`TopicSyncResult::dropped_graphs`]).
5778#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5779pub struct DroppedDeferral {
5780 /// The peer URL.
5781 pub peer: String,
5782 /// The graph's root outpoint.
5783 pub outpoint: String,
5784 /// [`crate::gasp::DropReason::as_str`].
5785 pub reason: String,
5786}
5787
5788/// One peer's persisted `last_interaction` cursor moving `from` -> `to` in a
5789/// sync ([`TopicSyncResult::cursor_moves`]).
5790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5791pub struct CursorMove {
5792 /// The peer URL.
5793 pub peer: String,
5794 /// The cursor the sync entered with.
5795 pub from: u64,
5796 /// The cursor it persisted.
5797 pub to: u64,
5798}
5799
5800/// Get current time in milliseconds (for output scores).
5801///
5802/// Uses `js_sys::Date::now()` on wasm32 (Cloudflare Workers) since
5803/// `std::time::SystemTime` is not available on that platform.
5804fn current_timestamp_ms() -> f64 {
5805 #[cfg(target_arch = "wasm32")]
5806 {
5807 js_sys::Date::now()
5808 }
5809 #[cfg(not(target_arch = "wasm32"))]
5810 {
5811 std::time::SystemTime::now()
5812 .duration_since(std::time::UNIX_EPOCH)
5813 .map_or(0.0, |d| d.as_millis() as f64)
5814 }
5815}
5816
5817/// Extract the domain field from a SHIP PushDrop locking script.
5818///
5819/// SHIP PushDrop format: field[0] = "SHIP", field[1] = identity_key,
5820/// field[2] = domain (UTF-8), field[3] = topic.
5821/// Returns `None` if the script cannot be parsed or is not a SHIP advertisement.
5822fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
5823 use bsv_rs::script::templates::PushDrop;
5824
5825 let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
5826 let pushdrop = PushDrop::decode(&script.into()).ok()?;
5827
5828 if pushdrop.fields.len() < 3 {
5829 return None;
5830 }
5831
5832 let protocol = String::from_utf8_lossy(&pushdrop.fields[0]);
5833 if protocol != "SHIP" {
5834 return None;
5835 }
5836
5837 let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
5838 if domain.is_empty() {
5839 return None;
5840 }
5841
5842 Some(domain)
5843}
5844
5845// ============================================================================
5846// Submit-time spend verification helpers (reference parity, 2026-09-08)
5847// ============================================================================
5848
5849// ============================================================================
5850// Error type
5851// ============================================================================
5852
5853#[derive(Debug, thiserror::Error)]
5854pub enum EngineError {
5855 #[error("This server does not support this topic: {0}")]
5856 UnsupportedTopic(String),
5857
5858 #[error("Lookup service not found for provider: {0}")]
5859 LookupServiceNotFound(String),
5860
5861 /// GASP: `/requestForeignGASPNode` asked for a graph/txid we don't
5862 /// have locally. Matches mainline's 400-class "not found" response.
5863 #[error("No matching output found!")]
5864 NodeNotFound,
5865
5866 #[error("lookup failed: {0}")]
5867 LookupFailed(String),
5868
5869 /// The CALLER's query was malformed — a bad identity key, a gameId that is
5870 /// not 32 bytes of hex, a missing required field. Distinct from
5871 /// `LookupFailed`, which means WE failed: without the distinction every
5872 /// caller mistake was reported as a server fault (HTTP 500), so a malformed
5873 /// query and a genuine outage were indistinguishable in logs and alerts on
5874 /// an unauthenticated endpoint anyone can hit.
5875 #[error("invalid query: {0}")]
5876 InvalidQuery(String),
5877
5878 #[error("storage error: {0}")]
5879 StorageError(String),
5880
5881 #[error("broadcast error: {0}")]
5882 BroadcastError(String),
5883
5884 #[error("SPV verification failed: {0}")]
5885 SpvError(String),
5886
5887 #[error("BEEF parsing failed: {0}")]
5888 BeefParseError(String),
5889
5890 /// A spend in the submitted BEEF does not satisfy its source output's
5891 /// locking script: the input's unlocking script was EXECUTED (reference
5892 /// parity, 2026-09-08) and the interpreter refused it. Distinct from
5893 /// [`EngineError::SpvError`] (a bad or unverifiable PROOF, a missing
5894 /// source, a chain-tracker fault) so an operator can tell a bad spend from
5895 /// a bad proof. `input_index` is the failing input's position in the
5896 /// transaction whose script failed (the subject, or an unproven
5897 /// ancestor of it in the same BEEF); `reason` is the interpreter's own
5898 /// message.
5899 #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
5900 ScriptVerificationFailed {
5901 subject_txid: String,
5902 input_index: u32,
5903 reason: String,
5904 },
5905
5906 /// The DOOR walk ([`Engine::verify_scripts_only`]) hit a STRUCTURAL fault
5907 /// before reaching a verdict on `at_txid` (a source the BEEF lacks, a
5908 /// parse fault, the value rule): not the interpreter's verdict, so not a
5909 /// refusal for a caller whose bar is the network. `subject_judged` says
5910 /// whether the subject's own inputs had all executed before the fault
5911 /// (an ancestor faulted) or not (the subject itself is unjudged).
5912 #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
5913 ScriptWalkInconclusive {
5914 at_txid: String,
5915 subject_judged: bool,
5916 reason: String,
5917 },
5918
5919 /// The DOOR walk exceeded its own bound ([`DoorBudget`]: the static work
5920 /// estimate, the interpreter's memory limit tripping, or the estimate of
5921 /// the door's own memory; `limb` says which): the door's verdict, never
5922 /// the network's — the request proceeds to the network.
5923 #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
5924 ScriptWalkOverBudget {
5925 at_txid: String,
5926 subject_judged: bool,
5927 limb: DoorLimb,
5928 what: String,
5929 },
5930
5931 /// The submit's own script walk COULD NOT RUN within the engine's
5932 /// [`DoorBudget`] (bsv-low #592) and nothing on record stands in for it
5933 /// ([`WalkBreachPolicy::NotNow`], every door but the queue replay of a
5934 /// gated submission; the E592 lens fold, H1). NOT NOW: nothing was
5935 /// judged, nothing written, and it is NOT a refusal either: the budget
5936 /// says nothing about the transaction. The way out is the subject
5937 /// PROVEN: a proven transaction is not walked, so an honest body too
5938 /// big to walk unmined lands once its block is mined (a client retries
5939 /// after the block, not at once).
5940 #[error(
5941 "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
5942 .0.limb.as_str(),
5943 .0.what
5944 )]
5945 WalkCouldNotRun(WalkCouldNotRun),
5946
5947 #[error("{0}")]
5948 Other(String),
5949}
5950
5951impl From<StorageError> for EngineError {
5952 fn from(e: StorageError) -> Self {
5953 EngineError::StorageError(e.to_string())
5954 }
5955}
5956
5957impl From<TopicManagerError> for EngineError {
5958 fn from(e: TopicManagerError) -> Self {
5959 EngineError::Other(e.to_string())
5960 }
5961}
5962
5963impl From<LookupServiceError> for EngineError {
5964 fn from(e: LookupServiceError) -> Self {
5965 EngineError::LookupFailed(e.to_string())
5966 }
5967}
5968
5969impl From<AdvertiserError> for EngineError {
5970 fn from(e: AdvertiserError) -> Self {
5971 EngineError::Other(e.to_string())
5972 }
5973}
5974
5975// ============================================================================
5976// Tests
5977// ============================================================================
5978
5979/// The order `start_gasp_sync` takes its topics in (bsv-low #555, the delta
5980/// fold's D-M1): topics with CONFIGURED peers (`SyncTarget::Peers`) first,
5981/// then those whose peers `ls_ship` discovers (anyone may advertise a host
5982/// there), each by name. The deferred-graph records share one storage
5983/// ceiling; a place it frees goes to a configured peer before a discovered
5984/// one, and a pass the outer deadline cuts has reached the configured peers.
5985/// The map's own order was arbitrary.
5986pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
5987 let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
5988 order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
5989 order
5990}
5991
5992#[cfg(test)]
5993mod tests {
5994 use super::*;
5995 use crate::lookup_service::LookupService as LookupServiceTrait;
5996 use crate::storage::memory::MemoryStorage;
5997 use crate::topic_manager::TopicManager as TopicManagerTrait;
5998 use async_trait::async_trait;
5999 use std::sync::Mutex;
6000
6001 // bsv-low #555, the delta fold's D-M1: the configured peers' topics are
6002 // synced before the ls_ship-discovered ones, each class by name; the map's
6003 // order was arbitrary.
6004 #[test]
6005 fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6006 let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6007 let config: SyncConfiguration = HashMap::from([
6008 ("tm_a".to_string(), SyncTarget::Ship),
6009 ("tm_z".to_string(), peers("https://z")),
6010 ("tm_b".to_string(), SyncTarget::Ship),
6011 ("tm_m".to_string(), peers("https://m")),
6012 ("tm_off".to_string(), SyncTarget::Disabled),
6013 ]);
6014 let order: Vec<&str> = sync_order(&config)
6015 .iter()
6016 .map(|(t, _)| t.as_str())
6017 .collect();
6018 assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6019 }
6020
6021 // ── Mock TopicManager ──────────────────────────────────────────────
6022
6023 struct MockTopicManager {
6024 admit_indices: Vec<u32>,
6025 }
6026
6027 impl MockTopicManager {
6028 fn admitting(indices: Vec<u32>) -> Self {
6029 Self {
6030 admit_indices: indices,
6031 }
6032 }
6033 }
6034
6035 #[async_trait(?Send)]
6036 impl TopicManagerTrait for MockTopicManager {
6037 async fn identify_admissible_outputs(
6038 &self,
6039 _tx: &Transaction,
6040 _previous_coins: &[u8],
6041 _off_chain_values: Option<&[u8]>,
6042 _mode: SubmitMode,
6043 _context: &TopicAdmittanceContext,
6044 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6045 Ok(AdmittanceInstructions {
6046 outputs_to_admit: self.admit_indices.clone(),
6047 coins_to_retain: vec![],
6048 coins_removed: None,
6049 })
6050 }
6051
6052 async fn get_documentation(&self) -> String {
6053 "Mock topic manager".to_string()
6054 }
6055
6056 async fn get_metadata(&self) -> ServiceMetadata {
6057 ServiceMetadata {
6058 name: "mock-tm".to_string(),
6059 description: Some("Mock for testing".to_string()),
6060 ..Default::default()
6061 }
6062 }
6063 }
6064
6065 // ── Mock LookupService ─────────────────────────────────────────────
6066
6067 struct MockLookupService {
6068 records: Mutex<Vec<UTXOReference>>,
6069 }
6070
6071 impl MockLookupService {
6072 fn new() -> Self {
6073 Self {
6074 records: Mutex::new(Vec::new()),
6075 }
6076 }
6077 }
6078
6079 #[async_trait(?Send)]
6080 impl LookupServiceTrait for MockLookupService {
6081 fn admission_mode(&self) -> AdmissionMode {
6082 AdmissionMode::LockingScript
6083 }
6084
6085 fn spend_notification_mode(&self) -> SpendNotificationMode {
6086 SpendNotificationMode::None
6087 }
6088
6089 async fn output_admitted_by_topic(
6090 &self,
6091 payload: &OutputAdmittedByTopic,
6092 ) -> Result<(), LookupServiceError> {
6093 let (txid, oi) = match payload {
6094 OutputAdmittedByTopic::LockingScript {
6095 txid, output_index, ..
6096 } => (txid.clone(), *output_index),
6097 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
6098 ("whole".into(), *output_index)
6099 }
6100 };
6101 self.records.lock().unwrap().push(UTXOReference {
6102 txid,
6103 output_index: oi,
6104 });
6105 Ok(())
6106 }
6107
6108 async fn output_evicted(
6109 &self,
6110 txid: &str,
6111 output_index: u32,
6112 ) -> Result<(), LookupServiceError> {
6113 self.records
6114 .lock()
6115 .unwrap()
6116 .retain(|r| !(r.txid == txid && r.output_index == output_index));
6117 Ok(())
6118 }
6119
6120 async fn lookup(
6121 &self,
6122 _question: &LookupQuestion,
6123 ) -> Result<LookupResult, LookupServiceError> {
6124 Ok(LookupResult::OutputList(
6125 self.records.lock().unwrap().clone(),
6126 ))
6127 }
6128
6129 async fn get_documentation(&self) -> String {
6130 "Mock lookup service".to_string()
6131 }
6132
6133 async fn get_metadata(&self) -> ServiceMetadata {
6134 ServiceMetadata {
6135 name: "mock-ls".to_string(),
6136 ..Default::default()
6137 }
6138 }
6139 }
6140
6141 // ── Test BEEF data ──────────────────────────────────────────────────
6142
6143 /// Real BRC-62 BEEF from TS overlay-services test suite.
6144 const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
6145 const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
6146
6147 fn decode_hex(hex: &str) -> Vec<u8> {
6148 (0..hex.len())
6149 .step_by(2)
6150 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
6151 .collect()
6152 }
6153
6154 fn test_beef() -> Vec<u8> {
6155 decode_hex(TEST_BEEF_HEX)
6156 }
6157
6158 fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
6159 TaggedBEEF::new(
6160 test_beef(),
6161 topics.into_iter().map(str::to_string).collect(),
6162 )
6163 }
6164
6165 // ── Helpers ────────────────────────────────────────────────────────
6166
6167 fn make_engine(admit_indices: Vec<u32>) -> Engine {
6168 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6169 managers.insert(
6170 "tm_test".to_string(),
6171 Box::new(MockTopicManager::admitting(admit_indices)),
6172 );
6173
6174 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
6175 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
6176
6177 let storage = Box::new(MemoryStorage::new());
6178
6179 Engine::new(
6180 managers,
6181 lookup_services,
6182 storage,
6183 None,
6184 EngineConfig::default(),
6185 )
6186 }
6187
6188 // ── Tests ──────────────────────────────────────────────────────────
6189
6190 /// bsv-low#289 parse-once + zanaadu#284 subject discipline: the tx handed
6191 /// to every topic manager must be the ATOMIC SUBJECT of the submitted
6192 /// BEEF, not whatever sits last in wire order. bsv-rs < 0.3.20's
6193 /// `from_beef(_, None)` picked `txs.last()`; wallet serializers do not all
6194 /// place the subject last, so a TM could be handed a PARENT to validate.
6195 /// This pins the engine on the fixed rule (`txid ?? atomicTxid ?? last`).
6196 #[tokio::test]
6197 async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
6198 use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
6199 use std::cell::RefCell;
6200 use std::rc::Rc;
6201
6202 // Rebuild the fixture chain SUBJECT-FIRST via to_writer (to_binary
6203 // re-sorts parents-first and would hide the trigger).
6204 let parsed = Beef::from_binary(&test_beef()).unwrap();
6205 let parent = parsed.txs[0].tx().unwrap().clone();
6206 let child = parsed.txs[1].tx().unwrap().clone();
6207 let child_id = child.id();
6208 let parent_id = parent.id();
6209 let mut beef = Beef::new();
6210 // The parent's BUMP rides along: the reference-parity walk
6211 // (2026-09-08) executes the subject's script against the parent and
6212 // then trusts the parent on its proof; without the BUMP it would
6213 // demand the parent's own ancestry ("Input 0 has no source
6214 // transaction"), which this fixture does not carry.
6215 for bump in &parsed.bumps {
6216 beef.merge_bump(bump.clone());
6217 }
6218 beef.merge_transaction(child);
6219 beef.merge_transaction(parent);
6220 let mut w = bsv_rs::primitives::encoding::Writer::new();
6221 w.write_u32_le(ATOMIC_BEEF);
6222 let mut le = hex::decode(&child_id).unwrap();
6223 le.reverse();
6224 w.write_bytes(&le);
6225 beef.to_writer(&mut w);
6226 let atomic_wire_order = w.into_bytes();
6227
6228 // A TM that records the txid it is handed.
6229 struct RecordingTm(Rc<RefCell<Option<String>>>);
6230 #[async_trait(?Send)]
6231 impl TopicManagerTrait for RecordingTm {
6232 async fn identify_admissible_outputs(
6233 &self,
6234 tx: &Transaction,
6235 _previous_coins: &[u8],
6236 _off_chain_values: Option<&[u8]>,
6237 _mode: SubmitMode,
6238 _context: &TopicAdmittanceContext,
6239 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6240 *self.0.borrow_mut() = Some(tx.id());
6241 Ok(AdmittanceInstructions {
6242 outputs_to_admit: vec![0],
6243 coins_to_retain: vec![],
6244 coins_removed: None,
6245 })
6246 }
6247 async fn get_documentation(&self) -> String {
6248 String::new()
6249 }
6250 async fn get_metadata(&self) -> ServiceMetadata {
6251 ServiceMetadata {
6252 name: "tm_test".to_string(),
6253 ..Default::default()
6254 }
6255 }
6256 }
6257
6258 let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
6259 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6260 managers.insert(
6261 "tm_test".to_string(),
6262 Box::new(RecordingTm(Rc::clone(&seen))),
6263 );
6264 let engine = Engine::new(
6265 managers,
6266 HashMap::new(),
6267 Box::new(MemoryStorage::new()),
6268 None,
6269 EngineConfig::default(),
6270 );
6271
6272 let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
6273 let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
6274
6275 let handed = seen.borrow().clone().expect("TM was not invoked");
6276 assert_eq!(
6277 handed, child_id,
6278 "TM must be handed the atomic subject, got the wire-order pick"
6279 );
6280 assert_ne!(handed, parent_id);
6281 }
6282
6283 /// #4 REGRESSION: history hydration must never DOWNGRADE provenance.
6284 ///
6285 /// `get_utxo_history` used to `Transaction::from_beef(beef, None)` and then
6286 /// `tx.to_beef(true)`, which keeps only the subject and DROPS the stored
6287 /// BUMPs. Measured on prod before the fix: `ls_low` byGameId `73de6e48…`
6288 /// returned 1,172 B / 1 tx / 1 bump (PROVEN) with no header, and 676 B /
6289 /// 1 tx / 0 bumps with `x-history-depth: 3`. A caller asking for MORE
6290 /// provenance got a BEEF no wallet can verify — silently, behind a 200.
6291 ///
6292 /// There was NO test on this function at all, which is exactly how that
6293 /// shipped. This one asserts the invariant that matters: whatever else
6294 /// hydration does, a proof that went in comes back out.
6295 #[tokio::test]
6296 async fn history_hydration_preserves_the_stored_proof() {
6297 use bsv_rs::transaction::Beef;
6298
6299 let engine = make_engine(vec![0]);
6300 let stored = test_beef();
6301 let bumps_before = Beef::from_binary(&stored)
6302 .expect("fixture BEEF parses")
6303 .bumps
6304 .len();
6305 assert!(
6306 bumps_before > 0,
6307 "fixture must actually carry a proof, or this cell proves nothing"
6308 );
6309
6310 let output = Output {
6311 txid: TEST_TXID.to_string(),
6312 output_index: 0,
6313 output_script: vec![0x76, 0xa9],
6314 satoshis: 1000,
6315 topic: "tm_test".to_string(),
6316 spent: false,
6317 // No admitted parents: hydration has nothing to add, so anything
6318 // it REMOVES is pure loss — the cleanest form of the bug.
6319 outputs_consumed: vec![],
6320 consumed_by: vec![],
6321 beef: Some(stored.clone()),
6322 block_height: None,
6323 score: Some(1000.0),
6324 };
6325
6326 let hydrated = engine
6327 .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
6328 .await
6329 .expect("hydration must not error")
6330 .expect("hydration must return the output");
6331 let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
6332 .expect("hydrated BEEF parses");
6333
6334 assert_eq!(
6335 beef.bumps.len(),
6336 bumps_before,
6337 "asking for history must not strip the merkle proof (issue #4)"
6338 );
6339 }
6340
6341 #[tokio::test]
6342 async fn test_submit_unsupported_topic_errors() {
6343 let engine = make_engine(vec![0]);
6344 let beef = test_tagged_beef(vec!["tm_nonexistent"]);
6345
6346 let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
6347 assert!(result.is_err());
6348 assert!(matches!(
6349 result.unwrap_err(),
6350 EngineError::UnsupportedTopic(_)
6351 ));
6352 }
6353
6354 #[tokio::test]
6355 async fn test_submit_admits_outputs() {
6356 let engine = make_engine(vec![0, 1]);
6357 let beef = test_tagged_beef(vec!["tm_test"]);
6358
6359 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6360
6361 // STEAK should have tm_test with outputs 0 and 1 admitted
6362 let instructions = steak.get("tm_test").unwrap();
6363 assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
6364
6365 // Outputs should be in storage with real data
6366 let txid = TEST_TXID;
6367 let found = engine
6368 .storage()
6369 .find_output(txid, 0, Some("tm_test"), None, true)
6370 .await
6371 .unwrap()
6372 .expect("Output 0 should be in storage");
6373
6374 assert_eq!(found.txid, TEST_TXID);
6375 assert_eq!(found.satoshis, 26172); // Real value from BRC62 BEEF
6376 assert!(
6377 !found.output_script.is_empty(),
6378 "Script should be populated"
6379 );
6380 assert!(found.beef.is_some(), "BEEF should be stored");
6381 assert!(!found.spent);
6382 }
6383
6384 #[tokio::test]
6385 async fn test_submit_duplicate_is_skipped() {
6386 let engine = make_engine(vec![0]);
6387 let beef = test_tagged_beef(vec!["tm_test"]);
6388
6389 // First submit
6390 let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6391 assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
6392
6393 // Second submit — should be a dupe, no new outputs admitted
6394 let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6395 assert!(steak2["tm_test"].outputs_to_admit.is_empty());
6396 }
6397
6398 #[tokio::test]
6399 async fn test_submit_notifies_lookup_service() {
6400 let engine = make_engine(vec![0]);
6401 let beef = test_tagged_beef(vec!["tm_test"]);
6402
6403 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6404
6405 // Lookup service should have the output
6406 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6407 let answer = engine.lookup(&question, None).await.unwrap();
6408
6409 match answer {
6410 LookupAnswer::OutputList { outputs } => {
6411 assert_eq!(outputs.len(), 1);
6412 }
6413 _ => panic!("Expected OutputList"),
6414 }
6415 }
6416
6417 /// #289: `lookup_with_txids` returns the storage txid per hydrated
6418 /// output, aligned with the OutputList — the aggregated serializer
6419 /// writes it instead of re-hashing the BEEF.
6420 #[tokio::test]
6421 async fn test_lookup_with_txids_aligns_txids_with_outputs() {
6422 let engine = make_engine(vec![0]);
6423 let beef = test_tagged_beef(vec!["tm_test"]);
6424 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6425
6426 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6427 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
6428 match answer {
6429 LookupAnswer::OutputList { outputs } => {
6430 assert_eq!(outputs.len(), 1);
6431 assert_eq!(txids.len(), outputs.len(), "one txid per output");
6432 assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
6433 }
6434 _ => panic!("Expected OutputList"),
6435 }
6436 }
6437
6438 /// #291: the public sync-response page size is always bounded.
6439 #[test]
6440 fn sync_limit_clamp() {
6441 assert_eq!(
6442 Engine::clamp_sync_limit(None),
6443 Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
6444 "absent limit gets the default page, never unbounded"
6445 );
6446 assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
6447 assert_eq!(
6448 Engine::clamp_sync_limit(Some(u64::MAX)),
6449 Engine::SYNC_RESPONSE_MAX_LIMIT,
6450 "a huge caller-supplied limit is capped"
6451 );
6452 }
6453
6454 #[tokio::test]
6455 async fn test_lookup_unknown_service_errors() {
6456 let engine = make_engine(vec![]);
6457 let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
6458
6459 let result = engine.lookup(&question, None).await;
6460 assert!(result.is_err());
6461 assert!(matches!(
6462 result.unwrap_err(),
6463 EngineError::LookupServiceNotFound(_)
6464 ));
6465 }
6466
6467 #[tokio::test]
6468 async fn test_list_topic_managers() {
6469 let engine = make_engine(vec![]);
6470 let managers = engine.list_topic_managers().await;
6471
6472 assert_eq!(managers.len(), 1);
6473 assert!(managers.contains_key("tm_test"));
6474 assert_eq!(managers["tm_test"].name, "mock-tm");
6475 }
6476
6477 #[tokio::test]
6478 async fn test_list_lookup_service_providers() {
6479 let engine = make_engine(vec![]);
6480 let services = engine.list_lookup_service_providers().await;
6481
6482 assert_eq!(services.len(), 1);
6483 assert!(services.contains_key("ls_test"));
6484 assert_eq!(services["ls_test"].name, "mock-ls");
6485 }
6486
6487 #[tokio::test]
6488 async fn test_get_documentation() {
6489 let engine = make_engine(vec![]);
6490
6491 let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
6492 assert_eq!(tm_docs, "Mock topic manager");
6493
6494 let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
6495 assert_eq!(ls_docs, "Mock lookup service");
6496
6497 let missing = engine
6498 .get_documentation_for_topic_manager("tm_missing")
6499 .await;
6500 assert_eq!(missing, "No documentation found!");
6501 }
6502
6503 #[tokio::test]
6504 async fn test_provide_foreign_sync_response() {
6505 let engine = make_engine(vec![0]);
6506
6507 // Submit data
6508 let beef = test_tagged_beef(vec!["tm_test"]);
6509 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6510
6511 // Query GASP sync
6512 let request = GASPInitialRequest {
6513 version: 1,
6514 since: 0,
6515 limit: Some(100),
6516 };
6517 let response = engine
6518 .provide_foreign_sync_response(&request, "tm_test")
6519 .await
6520 .unwrap();
6521
6522 // One tx admitted with output index 0
6523 assert_eq!(response.utxo_list.len(), 1);
6524 assert_eq!(response.utxo_list[0].txid, TEST_TXID);
6525 assert_eq!(response.since, 0);
6526 }
6527
6528 #[tokio::test]
6529 async fn test_delete_utxo_deep_simple() {
6530 let engine = make_engine(vec![0]);
6531 let beef = test_tagged_beef(vec!["tm_test"]);
6532
6533 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6534
6535 let txid = TEST_TXID;
6536 let output = engine
6537 .storage()
6538 .find_output(txid, 0, Some("tm_test"), None, false)
6539 .await
6540 .unwrap()
6541 .unwrap();
6542
6543 // Delete it
6544 engine.delete_utxo_deep(&output, None).await.unwrap();
6545
6546 // Should be gone
6547 let found = engine
6548 .storage()
6549 .find_output(txid, 0, Some("tm_test"), None, false)
6550 .await
6551 .unwrap();
6552 assert!(found.is_none());
6553 }
6554
6555 #[tokio::test]
6556 async fn test_sync_config_defaults_to_ship() {
6557 let engine = make_engine(vec![]);
6558 let sync = &engine.config.sync_configuration;
6559
6560 // tm_test should default to SHIP sync
6561 assert!(sync.contains_key("tm_test"));
6562 assert!(matches!(sync["tm_test"], SyncTarget::Ship));
6563 }
6564
6565 #[tokio::test]
6566 async fn test_multiple_topics_in_single_submit() {
6567 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6568 managers.insert(
6569 "tm_alpha".to_string(),
6570 Box::new(MockTopicManager::admitting(vec![0])),
6571 );
6572 managers.insert(
6573 "tm_beta".to_string(),
6574 Box::new(MockTopicManager::admitting(vec![1])),
6575 );
6576
6577 let engine = Engine::new(
6578 managers,
6579 HashMap::new(),
6580 Box::new(MemoryStorage::new()),
6581 None,
6582 EngineConfig::default(),
6583 );
6584
6585 let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
6586
6587 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6588
6589 assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
6590 assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
6591 }
6592
6593 #[tokio::test]
6594 async fn test_evict_output_with_topic() {
6595 let engine = make_engine(vec![0]);
6596 let beef = test_tagged_beef(vec!["tm_test"]);
6597 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6598
6599 // Output should exist
6600 let found = engine
6601 .storage()
6602 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
6603 .await
6604 .unwrap();
6605 assert!(found.is_some(), "Output should exist before eviction");
6606
6607 // Evict with topic
6608 engine
6609 .evict_output(TEST_TXID, 0, Some("tm_test"))
6610 .await
6611 .unwrap();
6612
6613 // Output should be gone from storage
6614 let found = engine
6615 .storage()
6616 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
6617 .await
6618 .unwrap();
6619 assert!(found.is_none(), "Output should be gone after eviction");
6620
6621 // Lookup service should also have been notified (output evicted)
6622 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6623 let answer = engine.lookup(&question, None).await.unwrap();
6624 match answer {
6625 LookupAnswer::OutputList { outputs } => {
6626 assert!(
6627 outputs.is_empty(),
6628 "Lookup should return empty after eviction"
6629 );
6630 }
6631 _ => panic!("Expected OutputList"),
6632 }
6633 }
6634
6635 #[tokio::test]
6636 async fn test_evict_output_without_topic() {
6637 let engine = make_engine(vec![0]);
6638 let beef = test_tagged_beef(vec!["tm_test"]);
6639 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6640
6641 // Evict without topic — should find and remove across all topics
6642 engine.evict_output(TEST_TXID, 0, None).await.unwrap();
6643
6644 let found = engine
6645 .storage()
6646 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
6647 .await
6648 .unwrap();
6649 assert!(
6650 found.is_none(),
6651 "Output should be gone after topic-less eviction"
6652 );
6653 }
6654
6655 #[tokio::test]
6656 async fn test_evict_nonexistent_output_is_ok() {
6657 let engine = make_engine(vec![]);
6658
6659 // Evicting something that doesn't exist should not error
6660 let result = engine
6661 .evict_output("nonexistent_txid", 99, Some("tm_test"))
6662 .await;
6663 assert!(result.is_ok(), "Evicting nonexistent output should succeed");
6664 }
6665
6666 // ── Mock Broadcaster ──────────────────────────────────────────────
6667
6668 use crate::broadcaster::Broadcaster;
6669 use std::sync::Arc;
6670
6671 type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
6672
6673 struct MockBroadcaster {
6674 calls: BroadcastCallLog,
6675 }
6676
6677 impl MockBroadcaster {
6678 fn new() -> Self {
6679 Self {
6680 calls: Arc::new(Mutex::new(Vec::new())),
6681 }
6682 }
6683
6684 #[allow(dead_code, reason = "kept for future test extension")]
6685 fn call_count(&self) -> usize {
6686 self.calls.lock().unwrap().len()
6687 }
6688
6689 #[allow(dead_code, reason = "kept for future test extension")]
6690 fn calls(&self) -> Vec<(String, Vec<String>)> {
6691 self.calls.lock().unwrap().clone()
6692 }
6693 }
6694
6695 #[async_trait(?Send)]
6696 impl Broadcaster for MockBroadcaster {
6697 async fn broadcast_to_host(
6698 &self,
6699 host_url: &str,
6700 tagged_beef: &TaggedBEEF,
6701 ) -> Result<(), String> {
6702 self.calls
6703 .lock()
6704 .unwrap()
6705 .push((host_url.to_string(), tagged_beef.topics.clone()));
6706 Ok(())
6707 }
6708 }
6709
6710 // ── Mock SHIP LookupService ─────────────────────────────────────
6711
6712 /// A mock "ls_ship" lookup service that returns pre-configured UTXOReferences
6713 /// when queried. Used to test SHIP propagation without depending on
6714 /// overlay-discovery in the engine crate's unit tests.
6715 struct MockSHIPLookupService {
6716 /// Maps topic -> list of (txid, output_index) references
6717 records: Mutex<HashMap<String, Vec<UTXOReference>>>,
6718 }
6719
6720 impl MockSHIPLookupService {
6721 fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
6722 Self {
6723 records: Mutex::new(records),
6724 }
6725 }
6726 }
6727
6728 #[async_trait(?Send)]
6729 impl LookupServiceTrait for MockSHIPLookupService {
6730 fn admission_mode(&self) -> AdmissionMode {
6731 AdmissionMode::LockingScript
6732 }
6733
6734 fn spend_notification_mode(&self) -> SpendNotificationMode {
6735 SpendNotificationMode::None
6736 }
6737
6738 async fn output_admitted_by_topic(
6739 &self,
6740 _payload: &OutputAdmittedByTopic,
6741 ) -> Result<(), LookupServiceError> {
6742 Ok(())
6743 }
6744
6745 async fn output_evicted(
6746 &self,
6747 _txid: &str,
6748 _output_index: u32,
6749 ) -> Result<(), LookupServiceError> {
6750 Ok(())
6751 }
6752
6753 async fn lookup(
6754 &self,
6755 question: &LookupQuestion,
6756 ) -> Result<LookupResult, LookupServiceError> {
6757 // Parse the topics from the query
6758 if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
6759 let records = self.records.lock().unwrap();
6760 let mut results = Vec::new();
6761 for topic_val in topics {
6762 if let Some(topic) = topic_val.as_str() {
6763 if let Some(refs) = records.get(topic) {
6764 results.extend(refs.iter().cloned());
6765 }
6766 }
6767 }
6768 return Ok(LookupResult::OutputList(results));
6769 }
6770 Ok(LookupResult::OutputList(vec![]))
6771 }
6772
6773 async fn get_documentation(&self) -> String {
6774 "Mock SHIP lookup service".to_string()
6775 }
6776
6777 async fn get_metadata(&self) -> ServiceMetadata {
6778 ServiceMetadata {
6779 name: "mock-ls-ship".to_string(),
6780 ..Default::default()
6781 }
6782 }
6783 }
6784
6785 /// Build a minimal PushDrop locking script for a SHIP advertisement.
6786 fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
6787 use bsv_rs::script::templates::PushDrop;
6788 use bsv_rs::PublicKey;
6789
6790 let fields = vec![
6791 b"SHIP".to_vec(),
6792 vec![0x02; 33], // fake compressed pubkey bytes for identity_key field
6793 domain.as_bytes().to_vec(),
6794 topic.as_bytes().to_vec(),
6795 ];
6796
6797 // PushDrop requires a real compressed public key for the locking script
6798 // Use a well-known test key (generator point G)
6799 let pubkey = PublicKey::from_hex(
6800 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
6801 )
6802 .expect("valid test pubkey");
6803
6804 let pd = PushDrop::new(pubkey, fields);
6805 pd.lock().to_binary()
6806 }
6807
6808 /// Create engine with a broadcaster and a mock SHIP lookup service.
6809 /// The `ship_domains` parameter provides (domain, topic) pairs to pre-populate.
6810 async fn make_engine_with_broadcaster(
6811 admit_indices: Vec<u32>,
6812 broadcaster: MockBroadcaster,
6813 ship_domains: Vec<(&str, &str)>,
6814 hosting_url: Option<&str>,
6815 ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
6816 let calls = broadcaster.calls.clone();
6817
6818 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6819 managers.insert(
6820 "tm_test".to_string(),
6821 Box::new(MockTopicManager::admitting(admit_indices)),
6822 );
6823
6824 let storage = Box::new(MemoryStorage::new());
6825
6826 // Build SHIP records: map each topic to its UTXO references and
6827 // store outputs in storage with PushDrop scripts so the engine can
6828 // parse the domain when broadcasting.
6829 let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
6830
6831 for (domain, topic) in &ship_domains {
6832 // Deterministic fake txid per domain+topic
6833 let fake_txid = format!("{:064x}", {
6834 let mut h: u64 = 0;
6835 for b in domain.bytes().chain(topic.bytes()) {
6836 h = h.wrapping_mul(31).wrapping_add(u64::from(b));
6837 }
6838 h
6839 });
6840
6841 // Add to SHIP records for the mock lookup service
6842 ship_records
6843 .entry(topic.to_string())
6844 .or_default()
6845 .push(UTXOReference {
6846 txid: fake_txid.clone(),
6847 output_index: 0,
6848 });
6849
6850 // Build PushDrop SHIP script and store as an output in main storage
6851 let script = build_ship_pushdrop_script(domain, topic);
6852
6853 let output = Output {
6854 txid: fake_txid,
6855 output_index: 0,
6856 output_script: script,
6857 satoshis: 1,
6858 topic: "tm_ship".to_string(),
6859 spent: false,
6860 outputs_consumed: vec![],
6861 consumed_by: vec![],
6862 beef: None,
6863 block_height: None,
6864 score: None,
6865 };
6866
6867 storage.insert_output(&output).await.unwrap();
6868 }
6869
6870 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
6871 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
6872 lookup_services.insert(
6873 "ls_ship".to_string(),
6874 Box::new(MockSHIPLookupService::with_records(ship_records)),
6875 );
6876
6877 let config = EngineConfig {
6878 hosting_url: hosting_url.map(str::to_string),
6879 ..Default::default()
6880 };
6881
6882 let engine = Engine::with_chain_tracker(
6883 managers,
6884 lookup_services,
6885 storage,
6886 None,
6887 Some(Box::new(broadcaster)),
6888 None,
6889 config,
6890 );
6891
6892 (engine, calls)
6893 }
6894
6895 // ── Broadcaster Tests ─────────────────────────────────────────────
6896
6897 #[tokio::test]
6898 async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
6899 let broadcaster = MockBroadcaster::new();
6900 let (engine, calls) = make_engine_with_broadcaster(
6901 vec![0],
6902 broadcaster,
6903 vec![("https://peer1.example.com", "tm_test")],
6904 Some("https://self.example.com"),
6905 )
6906 .await;
6907
6908 let beef = test_tagged_beef(vec!["tm_test"]);
6909 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6910
6911 // Submit should succeed
6912 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
6913
6914 // Broadcaster should have been called for peer1
6915 let recorded = calls.lock().unwrap();
6916 assert_eq!(
6917 recorded.len(),
6918 1,
6919 "Broadcaster should be called once for the one SHIP peer"
6920 );
6921 assert_eq!(recorded[0].0, "https://peer1.example.com");
6922 }
6923
6924 #[tokio::test]
6925 async fn test_broadcaster_not_called_on_historical_tx() {
6926 let broadcaster = MockBroadcaster::new();
6927 let (engine, calls) = make_engine_with_broadcaster(
6928 vec![0],
6929 broadcaster,
6930 vec![("https://peer1.example.com", "tm_test")],
6931 None,
6932 )
6933 .await;
6934
6935 let beef = test_tagged_beef(vec!["tm_test"]);
6936 let steak = engine
6937 .submit(&beef, SubmitMode::HistoricalTx)
6938 .await
6939 .unwrap();
6940
6941 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
6942
6943 // Broadcaster should NOT have been called
6944 assert_eq!(
6945 calls.lock().unwrap().len(),
6946 0,
6947 "Broadcaster should not be called for historical TX"
6948 );
6949 }
6950
6951 #[tokio::test]
6952 async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
6953 let broadcaster = MockBroadcaster::new();
6954 let (engine, calls) = make_engine_with_broadcaster(
6955 vec![0],
6956 broadcaster,
6957 vec![("https://peer1.example.com", "tm_test")],
6958 None,
6959 )
6960 .await;
6961
6962 let beef = test_tagged_beef(vec!["tm_test"]);
6963 let steak = engine
6964 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
6965 .await
6966 .unwrap();
6967
6968 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
6969
6970 assert_eq!(
6971 calls.lock().unwrap().len(),
6972 0,
6973 "Broadcaster should not be called for historical-tx-no-spv"
6974 );
6975 }
6976
6977 #[tokio::test]
6978 async fn test_broadcaster_skips_self_hosting_url() {
6979 let broadcaster = MockBroadcaster::new();
6980 let (engine, calls) = make_engine_with_broadcaster(
6981 vec![0],
6982 broadcaster,
6983 vec![
6984 ("https://self.example.com", "tm_test"),
6985 ("https://peer2.example.com", "tm_test"),
6986 ],
6987 Some("https://self.example.com"),
6988 )
6989 .await;
6990
6991 let beef = test_tagged_beef(vec!["tm_test"]);
6992 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6993
6994 // Should only broadcast to peer2, not to self
6995 let recorded = calls.lock().unwrap();
6996 assert_eq!(
6997 recorded.len(),
6998 1,
6999 "Should broadcast to peer2 only, skipping self"
7000 );
7001 assert_eq!(recorded[0].0, "https://peer2.example.com");
7002 }
7003
7004 #[tokio::test]
7005 async fn test_broadcaster_not_called_when_no_outputs_admitted() {
7006 let broadcaster = MockBroadcaster::new();
7007 // Topic manager admits nothing
7008 let (engine, calls) = make_engine_with_broadcaster(
7009 vec![],
7010 broadcaster,
7011 vec![("https://peer1.example.com", "tm_test")],
7012 None,
7013 )
7014 .await;
7015
7016 let beef = test_tagged_beef(vec!["tm_test"]);
7017 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7018
7019 assert!(steak["tm_test"].outputs_to_admit.is_empty());
7020
7021 // No outputs admitted -> no broadcast
7022 assert_eq!(
7023 calls.lock().unwrap().len(),
7024 0,
7025 "Broadcaster should not be called when no outputs are admitted"
7026 );
7027 }
7028
7029 #[tokio::test]
7030 async fn test_broadcaster_not_called_when_no_ship_peers() {
7031 let broadcaster = MockBroadcaster::new();
7032 // No SHIP peers registered
7033 let (engine, calls) = make_engine_with_broadcaster(
7034 vec![0],
7035 broadcaster,
7036 vec![], // no SHIP peers
7037 None,
7038 )
7039 .await;
7040
7041 let beef = test_tagged_beef(vec!["tm_test"]);
7042 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7043
7044 assert_eq!(
7045 calls.lock().unwrap().len(),
7046 0,
7047 "Broadcaster should not be called when no SHIP peers exist"
7048 );
7049 }
7050
7051 // ── Configurable SpendNotification mock ─────────────────────────────
7052
7053 /// A mock lookup service whose SpendNotificationMode can be configured.
7054 /// Uses a shared Arc<Mutex<Vec<OutputSpent>>> so the caller can inspect
7055 /// captured payloads after submit().
7056 struct SpendModeLookupService {
7057 mode: SpendNotificationMode,
7058 records: Mutex<Vec<UTXOReference>>,
7059 spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
7060 }
7061
7062 impl SpendModeLookupService {
7063 fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
7064 Self {
7065 mode,
7066 records: Mutex::new(Vec::new()),
7067 spent_payloads: capture,
7068 }
7069 }
7070 }
7071
7072 #[async_trait(?Send)]
7073 impl LookupServiceTrait for SpendModeLookupService {
7074 fn admission_mode(&self) -> AdmissionMode {
7075 AdmissionMode::LockingScript
7076 }
7077
7078 fn spend_notification_mode(&self) -> SpendNotificationMode {
7079 self.mode
7080 }
7081
7082 async fn output_admitted_by_topic(
7083 &self,
7084 payload: &OutputAdmittedByTopic,
7085 ) -> Result<(), LookupServiceError> {
7086 let (txid, oi) = match payload {
7087 OutputAdmittedByTopic::LockingScript {
7088 txid, output_index, ..
7089 } => (txid.clone(), *output_index),
7090 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
7091 ("whole".into(), *output_index)
7092 }
7093 };
7094 self.records.lock().unwrap().push(UTXOReference {
7095 txid,
7096 output_index: oi,
7097 });
7098 Ok(())
7099 }
7100
7101 async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
7102 self.spent_payloads.lock().unwrap().push(payload.clone());
7103 Ok(())
7104 }
7105
7106 async fn output_evicted(
7107 &self,
7108 txid: &str,
7109 output_index: u32,
7110 ) -> Result<(), LookupServiceError> {
7111 self.records
7112 .lock()
7113 .unwrap()
7114 .retain(|r| !(r.txid == txid && r.output_index == output_index));
7115 Ok(())
7116 }
7117
7118 async fn lookup(
7119 &self,
7120 _question: &LookupQuestion,
7121 ) -> Result<LookupResult, LookupServiceError> {
7122 Ok(LookupResult::OutputList(
7123 self.records.lock().unwrap().clone(),
7124 ))
7125 }
7126
7127 async fn get_documentation(&self) -> String {
7128 "SpendMode mock lookup service".to_string()
7129 }
7130
7131 async fn get_metadata(&self) -> ServiceMetadata {
7132 ServiceMetadata {
7133 name: "spend-mode-ls".to_string(),
7134 ..Default::default()
7135 }
7136 }
7137 }
7138
7139 /// TXID of the input's source transaction in TEST_BEEF_HEX (the "previous coin").
7140 const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
7141
7142 /// Build an engine with a pre-populated previous output and a SpendModeLookupService.
7143 /// Returns the engine and the shared capture vec for inspecting OutputSpent payloads.
7144 async fn make_spend_mode_engine(
7145 mode: SpendNotificationMode,
7146 ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
7147 let storage = MemoryStorage::new();
7148
7149 // Pre-populate storage with the previous output that TEST_BEEF_HEX's input spends.
7150 let prev_output = Output {
7151 txid: PREVIOUS_TXID.to_string(),
7152 output_index: 0,
7153 output_script: vec![0x76, 0xa9],
7154 satoshis: 26174,
7155 topic: "tm_test".to_string(),
7156 spent: false,
7157 outputs_consumed: vec![],
7158 consumed_by: vec![],
7159 beef: Some(test_beef()),
7160 block_height: None,
7161 score: Some(1000.0),
7162 };
7163 storage.insert_output(&prev_output).await.unwrap();
7164
7165 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7166 managers.insert(
7167 "tm_test".to_string(),
7168 Box::new(MockTopicManager::admitting(vec![0])),
7169 );
7170
7171 let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
7172 let ls = SpendModeLookupService::with_mode(mode, capture.clone());
7173 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7174 lookup_services.insert("ls_test".to_string(), Box::new(ls));
7175
7176 let engine = Engine::new(
7177 managers,
7178 lookup_services,
7179 Box::new(storage),
7180 None,
7181 EngineConfig::default(),
7182 );
7183
7184 (engine, capture)
7185 }
7186
7187 #[tokio::test]
7188 async fn test_spend_notification_mode_script() {
7189 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
7190
7191 let beef = test_tagged_beef(vec!["tm_test"]);
7192 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7193
7194 // The new output should be admitted
7195 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7196
7197 // The lookup service should have received a Script spend notification
7198 let payloads = capture.lock().unwrap().clone();
7199 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7200
7201 match &payloads[0] {
7202 OutputSpent::Script {
7203 txid,
7204 output_index,
7205 topic,
7206 spending_txid,
7207 input_index,
7208 unlocking_script,
7209 sequence_number,
7210 ..
7211 } => {
7212 assert_eq!(txid, PREVIOUS_TXID);
7213 assert_eq!(*output_index, 0);
7214 assert_eq!(topic, "tm_test");
7215 assert_eq!(spending_txid, TEST_TXID);
7216 // The BEEF has a single input at index 0 that spends PREVIOUS_TXID:0
7217 assert_eq!(*input_index, 0);
7218 // The unlocking script should be non-empty (it's a P2PKH scriptSig)
7219 assert!(
7220 !unlocking_script.is_empty(),
7221 "Unlocking script should be non-empty"
7222 );
7223 // Standard final sequence number
7224 assert_eq!(*sequence_number, 0xffff_ffff);
7225 }
7226 other => panic!("Expected OutputSpent::Script, got: {other:?}"),
7227 }
7228 }
7229
7230 #[tokio::test]
7231 async fn test_spend_notification_mode_whole_tx() {
7232 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
7233
7234 let beef = test_tagged_beef(vec!["tm_test"]);
7235 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7236
7237 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7238
7239 let payloads = capture.lock().unwrap().clone();
7240 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7241
7242 match &payloads[0] {
7243 OutputSpent::WholeTx {
7244 txid,
7245 output_index,
7246 topic,
7247 spending_atomic_beef,
7248 ..
7249 } => {
7250 assert_eq!(txid, PREVIOUS_TXID);
7251 assert_eq!(*output_index, 0);
7252 assert_eq!(topic, "tm_test");
7253 // The spending BEEF is the entire BEEF we submitted, NAMED
7254 // (atomic prefix) for the lookup service's own re-parse.
7255 let mut named =
7256 bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
7257 assert!(named.is_atomic());
7258 let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
7259 assert_eq!(
7260 named.txs.len(),
7261 submitted.txs.len(),
7262 "the whole body, never pruned"
7263 );
7264 let tip = crate::subject::subject_txid_of(&mut named).unwrap();
7265 assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
7266 }
7267 other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
7268 }
7269 }
7270
7271 #[tokio::test]
7272 async fn test_spend_notification_mode_txid() {
7273 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
7274
7275 let beef = test_tagged_beef(vec!["tm_test"]);
7276 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7277
7278 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7279
7280 let payloads = capture.lock().unwrap().clone();
7281 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7282
7283 match &payloads[0] {
7284 OutputSpent::Txid {
7285 txid,
7286 output_index,
7287 topic,
7288 spending_txid,
7289 } => {
7290 assert_eq!(txid, PREVIOUS_TXID);
7291 assert_eq!(*output_index, 0);
7292 assert_eq!(topic, "tm_test");
7293 assert_eq!(spending_txid, TEST_TXID);
7294 }
7295 other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
7296 }
7297 }
7298
7299 #[tokio::test]
7300 async fn test_spend_notification_mode_none() {
7301 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
7302
7303 let beef = test_tagged_beef(vec!["tm_test"]);
7304 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7305
7306 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7307
7308 let payloads = capture.lock().unwrap().clone();
7309 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7310
7311 match &payloads[0] {
7312 OutputSpent::None {
7313 txid,
7314 output_index,
7315 topic,
7316 } => {
7317 assert_eq!(txid, PREVIOUS_TXID);
7318 assert_eq!(*output_index, 0);
7319 assert_eq!(topic, "tm_test");
7320 }
7321 other => panic!("Expected OutputSpent::None, got: {other:?}"),
7322 }
7323 }
7324
7325 // ── GASP Sync Tests ───────────────────────────────────────────────
7326
7327 #[tokio::test]
7328 async fn test_start_gasp_sync_empty_config() {
7329 let engine = Engine::new(
7330 HashMap::new(),
7331 HashMap::new(),
7332 Box::new(MemoryStorage::new()),
7333 None,
7334 EngineConfig {
7335 sync_configuration: HashMap::new(),
7336 ..Default::default()
7337 },
7338 );
7339
7340 let result = engine.start_gasp_sync().await.unwrap();
7341 assert!(
7342 result.topics_synced.is_empty(),
7343 "No topics should be synced with empty config"
7344 );
7345 }
7346
7347 #[tokio::test]
7348 async fn test_start_gasp_sync_disabled_topic_skipped() {
7349 let mut sync_config: SyncConfiguration = HashMap::new();
7350 sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
7351
7352 let engine = Engine::new(
7353 HashMap::new(),
7354 HashMap::new(),
7355 Box::new(MemoryStorage::new()),
7356 None,
7357 EngineConfig {
7358 sync_configuration: sync_config,
7359 ..Default::default()
7360 },
7361 );
7362
7363 let result = engine.start_gasp_sync().await.unwrap();
7364 assert!(
7365 result.topics_synced.is_empty(),
7366 "Disabled topics should not appear in results"
7367 );
7368 }
7369
7370 #[tokio::test]
7371 async fn test_start_gasp_sync_peers_config() {
7372 let mut sync_config: SyncConfiguration = HashMap::new();
7373 sync_config.insert(
7374 "tm_test".to_string(),
7375 SyncTarget::Peers(vec![
7376 "https://peer1.example.com".to_string(),
7377 "https://peer2.example.com".to_string(),
7378 ]),
7379 );
7380
7381 let engine = Engine::new(
7382 HashMap::new(),
7383 HashMap::new(),
7384 Box::new(MemoryStorage::new()),
7385 None,
7386 EngineConfig {
7387 sync_configuration: sync_config,
7388 ..Default::default()
7389 },
7390 );
7391
7392 let result = engine.start_gasp_sync().await.unwrap();
7393 assert_eq!(result.topics_synced.len(), 1);
7394
7395 let topic_result = &result.topics_synced["tm_test"];
7396 assert_eq!(topic_result.peers.len(), 2);
7397 assert_eq!(topic_result.sync_type, "peers");
7398 assert!(topic_result.errors.is_empty());
7399 }
7400
7401 #[tokio::test]
7402 async fn test_start_gasp_sync_peers_filters_self() {
7403 let mut sync_config: SyncConfiguration = HashMap::new();
7404 sync_config.insert(
7405 "tm_test".to_string(),
7406 SyncTarget::Peers(vec![
7407 "https://self.example.com".to_string(),
7408 "https://peer2.example.com".to_string(),
7409 ]),
7410 );
7411
7412 let engine = Engine::new(
7413 HashMap::new(),
7414 HashMap::new(),
7415 Box::new(MemoryStorage::new()),
7416 None,
7417 EngineConfig {
7418 hosting_url: Some("https://self.example.com".to_string()),
7419 sync_configuration: sync_config,
7420 ..Default::default()
7421 },
7422 );
7423
7424 let result = engine.start_gasp_sync().await.unwrap();
7425 let topic_result = &result.topics_synced["tm_test"];
7426 assert_eq!(
7427 topic_result.peers.len(),
7428 1,
7429 "Self URL should be filtered out"
7430 );
7431 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
7432 }
7433
7434 #[tokio::test]
7435 async fn test_start_gasp_sync_ship_discovers_peers() {
7436 let broadcaster = MockBroadcaster::new();
7437 let (engine, _calls) = make_engine_with_broadcaster(
7438 vec![0],
7439 broadcaster,
7440 vec![
7441 ("https://peer1.example.com", "tm_test"),
7442 ("https://peer2.example.com", "tm_test"),
7443 ],
7444 Some("https://self.example.com"),
7445 )
7446 .await;
7447
7448 let result = engine.start_gasp_sync().await.unwrap();
7449 assert!(
7450 result.topics_synced.contains_key("tm_test"),
7451 "tm_test should be in sync results"
7452 );
7453
7454 let topic_result = &result.topics_synced["tm_test"];
7455 assert_eq!(topic_result.sync_type, "ship");
7456 assert_eq!(
7457 topic_result.peers.len(),
7458 2,
7459 "Should discover both SHIP peers"
7460 );
7461 assert!(topic_result
7462 .peers
7463 .contains(&"https://peer1.example.com".to_string()));
7464 assert!(topic_result
7465 .peers
7466 .contains(&"https://peer2.example.com".to_string()));
7467 }
7468
7469 #[tokio::test]
7470 async fn test_start_gasp_sync_ship_filters_self() {
7471 let broadcaster = MockBroadcaster::new();
7472 let (engine, _calls) = make_engine_with_broadcaster(
7473 vec![0],
7474 broadcaster,
7475 vec![
7476 ("https://self.example.com", "tm_test"),
7477 ("https://peer2.example.com", "tm_test"),
7478 ],
7479 Some("https://self.example.com"),
7480 )
7481 .await;
7482
7483 let result = engine.start_gasp_sync().await.unwrap();
7484 let topic_result = &result.topics_synced["tm_test"];
7485 assert_eq!(
7486 topic_result.peers.len(),
7487 1,
7488 "Self URL should be filtered out from SHIP discovery"
7489 );
7490 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
7491 }
7492
7493 #[tokio::test]
7494 async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
7495 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7496 managers.insert(
7497 "tm_custom".to_string(),
7498 Box::new(MockTopicManager::admitting(vec![])),
7499 );
7500
7501 let mut sync_config: SyncConfiguration = HashMap::new();
7502 sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
7503
7504 let engine = Engine::new(
7505 managers,
7506 HashMap::new(),
7507 Box::new(MemoryStorage::new()),
7508 None,
7509 EngineConfig {
7510 sync_configuration: sync_config,
7511 ..Default::default()
7512 },
7513 );
7514
7515 let result = engine.start_gasp_sync().await.unwrap();
7516 let topic_result = &result.topics_synced["tm_custom"];
7517 assert!(
7518 topic_result.peers.is_empty(),
7519 "Without ls_ship, SHIP discovery should return no peers"
7520 );
7521 }
7522
7523 #[tokio::test]
7524 async fn test_start_gasp_sync_mixed_config() {
7525 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7526 managers.insert(
7527 "tm_peers".to_string(),
7528 Box::new(MockTopicManager::admitting(vec![])),
7529 );
7530 managers.insert(
7531 "tm_disabled".to_string(),
7532 Box::new(MockTopicManager::admitting(vec![])),
7533 );
7534 managers.insert(
7535 "tm_ship_topic".to_string(),
7536 Box::new(MockTopicManager::admitting(vec![])),
7537 );
7538
7539 let mut sync_config: SyncConfiguration = HashMap::new();
7540 sync_config.insert(
7541 "tm_peers".to_string(),
7542 SyncTarget::Peers(vec!["https://peer.com".to_string()]),
7543 );
7544 sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
7545 sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
7546
7547 let engine = Engine::new(
7548 managers,
7549 HashMap::new(),
7550 Box::new(MemoryStorage::new()),
7551 None,
7552 EngineConfig {
7553 sync_configuration: sync_config,
7554 ..Default::default()
7555 },
7556 );
7557
7558 let result = engine.start_gasp_sync().await.unwrap();
7559 assert_eq!(result.topics_synced.len(), 2);
7560 assert!(result.topics_synced.contains_key("tm_peers"));
7561 assert!(result.topics_synced.contains_key("tm_ship_topic"));
7562 assert!(!result.topics_synced.contains_key("tm_disabled"));
7563
7564 assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
7565 assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
7566 assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
7567 }
7568
7569 #[tokio::test]
7570 async fn test_gasp_sync_result_serialization() {
7571 let mut topics_synced = HashMap::new();
7572 topics_synced.insert(
7573 "tm_test".to_string(),
7574 super::TopicSyncResult {
7575 peers: vec!["https://peer.com".to_string()],
7576 sync_type: "ship".to_string(),
7577 errors: vec![],
7578 pruned_inputs: 0,
7579 discarded_graphs: 0,
7580 anchor_walk_held_graphs: 0,
7581 finalized_graphs: 0,
7582 deadline_dropped_graphs: 0,
7583 cursor_moves: Vec::new(),
7584 deferred_graphs: 0,
7585 resumed_graphs: 0,
7586 converged_graphs: 0,
7587 dropped_graphs: Vec::new(),
7588 stalled_graphs: 0,
7589 held_back_graphs: 0,
7590 },
7591 );
7592
7593 let result = super::GASPSyncResult { topics_synced };
7594 let json = serde_json::to_string(&result).unwrap();
7595 let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
7596
7597 assert_eq!(back.topics_synced.len(), 1);
7598 assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
7599 assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
7600 }
7601
7602 // ── GASP Sync With Factory Tests ─────────────────────────────────
7603
7604 /// Mock GASPRemote for factory tests
7605 struct MockSyncRemote {
7606 utxos: Vec<crate::types::GASPOutput>,
7607 }
7608
7609 #[async_trait(?Send)]
7610 impl crate::gasp::GASPRemote for MockSyncRemote {
7611 async fn get_initial_response(
7612 &self,
7613 request: &crate::types::GASPInitialRequest,
7614 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
7615 let utxos: Vec<crate::types::GASPOutput> = self
7616 .utxos
7617 .iter()
7618 .filter(|u| u.score as u64 >= request.since)
7619 .cloned()
7620 .collect();
7621 Ok(crate::types::GASPInitialResponse {
7622 utxo_list: utxos,
7623 since: request.since,
7624 })
7625 }
7626 async fn get_initial_reply(
7627 &self,
7628 _: &crate::types::GASPInitialResponse,
7629 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
7630 Ok(crate::types::GASPInitialReply {
7631 utxo_list: Vec::new(),
7632 })
7633 }
7634 async fn request_node(
7635 &self,
7636 graph_id: &str,
7637 txid: &str,
7638 output_index: u32,
7639 _: bool,
7640 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
7641 Ok(crate::types::GASPNode {
7642 graph_id: graph_id.to_string(),
7643 raw_tx: format!("rawtx_{txid}"),
7644 output_index,
7645 proof: None,
7646 tx_metadata: None,
7647 output_metadata: None,
7648 inputs: None,
7649 })
7650 }
7651 async fn submit_node(
7652 &self,
7653 _: &crate::types::GASPNode,
7654 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
7655 Ok(None)
7656 }
7657 }
7658
7659 /// Mock factory that creates MockSyncRemote instances
7660 struct MockGASPRemoteFactory;
7661
7662 impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
7663 fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
7664 Box::new(MockSyncRemote {
7665 utxos: vec![
7666 crate::types::GASPOutput {
7667 txid: "remote_tx1".to_string(),
7668 output_index: 0,
7669 score: 100.0,
7670 },
7671 crate::types::GASPOutput {
7672 txid: "remote_tx2".to_string(),
7673 output_index: 0,
7674 score: 200.0,
7675 },
7676 ],
7677 })
7678 }
7679 }
7680
7681 #[tokio::test]
7682 async fn test_start_gasp_sync_with_factory_runs_sync() {
7683 let mut sync_config: SyncConfiguration = HashMap::new();
7684 sync_config.insert(
7685 "tm_test".to_string(),
7686 SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
7687 );
7688
7689 let mut engine = Engine::new(
7690 HashMap::new(),
7691 HashMap::new(),
7692 Box::new(MemoryStorage::new()),
7693 None,
7694 EngineConfig {
7695 sync_configuration: sync_config,
7696 ..Default::default()
7697 },
7698 );
7699
7700 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
7701
7702 let result = engine.start_gasp_sync().await.unwrap();
7703 assert_eq!(result.topics_synced.len(), 1);
7704
7705 let topic_result = &result.topics_synced["tm_test"];
7706 assert_eq!(topic_result.peers.len(), 1);
7707 assert!(
7708 topic_result.errors.is_empty(),
7709 "Sync should succeed with mock remote"
7710 );
7711
7712 // Verify last_interaction was persisted
7713 let last = engine
7714 .storage()
7715 .get_last_interaction("https://peer1.example.com", "tm_test")
7716 .await
7717 .unwrap();
7718 // The mock remote serves synthetic `rawtx_*` nodes that cannot assemble
7719 // into a real BEEF, so both graphs fail to INGEST (a transient-class
7720 // error). The cursor is therefore capped at 99 — strictly below the
7721 // lowest un-ingested score (remote_tx1@100) — so the next sync re-pulls
7722 // them rather than skipping past. (Pre-#43 this advanced to 200, the
7723 // highest *seen* score, silently stranding both graphs.)
7724 assert_eq!(
7725 last, 99,
7726 "cursor must cap below the lowest un-ingested score, not skip to the seen tip"
7727 );
7728 }
7729
7730 #[tokio::test]
7731 async fn test_start_gasp_sync_with_factory_handles_error() {
7732 struct FailingFactory;
7733
7734 impl crate::gasp::GASPRemoteFactory for FailingFactory {
7735 fn create_remote(
7736 &self,
7737 _peer_url: &str,
7738 _topic: &str,
7739 ) -> Box<dyn crate::gasp::GASPRemote> {
7740 struct FailingRemote;
7741 #[async_trait(?Send)]
7742 impl crate::gasp::GASPRemote for FailingRemote {
7743 async fn get_initial_response(
7744 &self,
7745 _: &crate::types::GASPInitialRequest,
7746 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
7747 {
7748 Err(crate::gasp::GASPError::RemoteError(
7749 "connection refused".into(),
7750 ))
7751 }
7752 async fn get_initial_reply(
7753 &self,
7754 _: &crate::types::GASPInitialResponse,
7755 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
7756 {
7757 unreachable!()
7758 }
7759 async fn request_node(
7760 &self,
7761 _: &str,
7762 _: &str,
7763 _: u32,
7764 _: bool,
7765 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
7766 {
7767 unreachable!()
7768 }
7769 async fn submit_node(
7770 &self,
7771 _: &crate::types::GASPNode,
7772 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
7773 {
7774 unreachable!()
7775 }
7776 }
7777 Box::new(FailingRemote)
7778 }
7779 }
7780
7781 let mut sync_config: SyncConfiguration = HashMap::new();
7782 sync_config.insert(
7783 "tm_test".to_string(),
7784 SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
7785 );
7786
7787 let mut engine = Engine::new(
7788 HashMap::new(),
7789 HashMap::new(),
7790 Box::new(MemoryStorage::new()),
7791 None,
7792 EngineConfig {
7793 sync_configuration: sync_config,
7794 ..Default::default()
7795 },
7796 );
7797
7798 engine.set_gasp_remote_factory(Box::new(FailingFactory));
7799
7800 let result = engine.start_gasp_sync().await.unwrap();
7801 assert_eq!(result.topics_synced.len(), 1);
7802
7803 let topic_result = &result.topics_synced["tm_test"];
7804 assert_eq!(
7805 topic_result.errors.len(),
7806 1,
7807 "Should have one error for the failing peer"
7808 );
7809 assert!(topic_result.errors[0].contains("connection refused"));
7810 }
7811
7812 #[tokio::test]
7813 async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
7814 let mut sync_config: SyncConfiguration = HashMap::new();
7815 sync_config.insert(
7816 "tm_test".to_string(),
7817 SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
7818 );
7819
7820 // No factory set — should still work (just peer discovery, no sync)
7821 let engine = Engine::new(
7822 HashMap::new(),
7823 HashMap::new(),
7824 Box::new(MemoryStorage::new()),
7825 None,
7826 EngineConfig {
7827 sync_configuration: sync_config,
7828 ..Default::default()
7829 },
7830 );
7831
7832 let result = engine.start_gasp_sync().await.unwrap();
7833 assert_eq!(result.topics_synced.len(), 1);
7834
7835 let topic_result = &result.topics_synced["tm_test"];
7836 assert_eq!(topic_result.peers.len(), 1);
7837 assert!(topic_result.errors.is_empty());
7838 }
7839
7840 #[tokio::test]
7841 async fn test_start_gasp_sync_with_factory_multiple_peers() {
7842 let mut sync_config: SyncConfiguration = HashMap::new();
7843 sync_config.insert(
7844 "tm_test".to_string(),
7845 SyncTarget::Peers(vec![
7846 "https://peer1.example.com".to_string(),
7847 "https://peer2.example.com".to_string(),
7848 ]),
7849 );
7850
7851 let mut engine = Engine::new(
7852 HashMap::new(),
7853 HashMap::new(),
7854 Box::new(MemoryStorage::new()),
7855 None,
7856 EngineConfig {
7857 sync_configuration: sync_config,
7858 ..Default::default()
7859 },
7860 );
7861
7862 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
7863
7864 let result = engine.start_gasp_sync().await.unwrap();
7865 let topic_result = &result.topics_synced["tm_test"];
7866 assert_eq!(topic_result.peers.len(), 2);
7867 assert!(topic_result.errors.is_empty());
7868
7869 // Both peers should have their last_interaction updated
7870 let last1 = engine
7871 .storage()
7872 .get_last_interaction("https://peer1.example.com", "tm_test")
7873 .await
7874 .unwrap();
7875 let last2 = engine
7876 .storage()
7877 .get_last_interaction("https://peer2.example.com", "tm_test")
7878 .await
7879 .unwrap();
7880 // Capped at 99 (below the lowest un-ingested score@100), not 200 — the
7881 // synthetic mock graphs fail to ingest, so the cursor must not skip past
7882 // them. See `test_start_gasp_sync_with_factory_runs_sync` (#43 gap-guard).
7883 assert_eq!(last1, 99);
7884 assert_eq!(last2, 99);
7885 }
7886
7887 // ── Per-peer budget + dead-peer quarantine (bsv-low#302) ──────────
7888
7889 /// A remote whose initial request HANGS forever — models the dead
7890 /// ephemeral peers (ngrok tunnels) whose fetch never returns, the #257
7891 /// root cause the per-peer budget exists for.
7892 struct HangingRemote;
7893
7894 #[async_trait(?Send)]
7895 impl crate::gasp::GASPRemote for HangingRemote {
7896 async fn get_initial_response(
7897 &self,
7898 _: &crate::types::GASPInitialRequest,
7899 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
7900 std::future::pending::<()>().await;
7901 unreachable!()
7902 }
7903 async fn get_initial_reply(
7904 &self,
7905 _: &crate::types::GASPInitialResponse,
7906 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
7907 unreachable!()
7908 }
7909 async fn request_node(
7910 &self,
7911 _: &str,
7912 _: &str,
7913 _: u32,
7914 _: bool,
7915 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
7916 unreachable!()
7917 }
7918 async fn submit_node(
7919 &self,
7920 _: &crate::types::GASPNode,
7921 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
7922 unreachable!()
7923 }
7924 }
7925
7926 /// Factory that HANGS for `hang.example.com` peers and serves the normal
7927 /// mock sync remote for everyone else, counting `create_remote` calls
7928 /// (a quarantine-skipped peer never reaches the factory).
7929 struct SelectiveHangFactory {
7930 created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
7931 }
7932
7933 impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
7934 fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
7935 self.created.borrow_mut().push(peer_url.to_string());
7936 if peer_url.contains("hang.example.com") {
7937 Box::new(HangingRemote)
7938 } else {
7939 Box::new(MockSyncRemote {
7940 utxos: vec![crate::types::GASPOutput {
7941 txid: "remote_tx1".to_string(),
7942 output_index: 0,
7943 score: 100.0,
7944 }],
7945 })
7946 }
7947 }
7948 }
7949
7950 /// The deterministic "instant deadline" sleep factory: `race_or_deadline`
7951 /// polls the sync future FIRST, so an in-memory sync that never yields
7952 /// still completes; only a future that actually returns Pending (the
7953 /// hanging remote) loses the race.
7954 fn instant_deadline() -> crate::engine::SleepFactory {
7955 std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
7956 }
7957
7958 fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
7959 let mut sync_config: SyncConfiguration = HashMap::new();
7960 sync_config.insert(
7961 "tm_test".to_string(),
7962 SyncTarget::Peers(vec![
7963 "https://hang.example.com".to_string(),
7964 "https://good.example.com".to_string(),
7965 ]),
7966 );
7967 Engine::new(
7968 HashMap::new(),
7969 HashMap::new(),
7970 Box::new(store),
7971 None,
7972 EngineConfig {
7973 sync_configuration: sync_config,
7974 ..Default::default()
7975 },
7976 )
7977 }
7978
7979 #[tokio::test]
7980 async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
7981 let store = std::rc::Rc::new(MemoryStorage::new());
7982 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
7983 let mut engine = two_peer_engine(store.clone());
7984 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
7985 created: created.clone(),
7986 }));
7987 engine.set_peer_sync_budget(instant_deadline(), 1);
7988
7989 let result = engine.start_gasp_sync().await.unwrap();
7990 let topic_result = &result.topics_synced["tm_test"];
7991
7992 // The hanging peer was dropped LOUDLY (an error entry) and the loop
7993 // CONTINUED: the good peer was still attempted and completed.
7994 assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
7995 assert!(
7996 topic_result.errors[0].contains("budget"),
7997 "{:?}",
7998 topic_result.errors
7999 );
8000 assert_eq!(
8001 created.borrow().as_slice(),
8002 ["https://hang.example.com", "https://good.example.com"],
8003 "both peers attempted this tick"
8004 );
8005
8006 // Cursor semantics (the #257 gate property): a timed-out peer's
8007 // cursor NEVER advances; the completed peer's does.
8008 let hang_cursor = store
8009 .get_last_interaction("https://hang.example.com", "tm_test")
8010 .await
8011 .unwrap();
8012 assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
8013 let good_cursor = store
8014 .get_last_interaction("https://good.example.com", "tm_test")
8015 .await
8016 .unwrap();
8017 assert!(good_cursor > 0, "completed peer's cursor advances");
8018
8019 // Health bookkeeping: timeout = failure, completion = success.
8020 let hang_health = store
8021 .get_peer_sync_health("hang.example.com", "tm_test")
8022 .await
8023 .unwrap();
8024 assert_eq!(hang_health.consecutive_failures, 1);
8025 let good_health = store
8026 .get_peer_sync_health("good.example.com", "tm_test")
8027 .await
8028 .unwrap();
8029 assert_eq!(good_health.consecutive_failures, 0);
8030 }
8031
8032 #[tokio::test]
8033 async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
8034 let store = std::rc::Rc::new(MemoryStorage::new());
8035 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8036
8037 let mut sync_config: SyncConfiguration = HashMap::new();
8038 sync_config.insert(
8039 "tm_test".to_string(),
8040 SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
8041 );
8042 let mut engine = Engine::new(
8043 HashMap::new(),
8044 HashMap::new(),
8045 Box::new(store.clone()),
8046 None,
8047 EngineConfig {
8048 sync_configuration: sync_config,
8049 ..Default::default()
8050 },
8051 );
8052 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
8053 created: created.clone(),
8054 }));
8055 engine.set_peer_sync_budget(instant_deadline(), 1);
8056
8057 // Fail the peer up to (but not past) the quarantine threshold — every
8058 // tick attempts it (advance the clock between ticks so the attempts
8059 // are distinguishable in age).
8060 for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
8061 engine.start_gasp_sync().await.unwrap();
8062 store.advance_clock(900);
8063 assert_eq!(
8064 created.borrow().len() as u64,
8065 i + 1,
8066 "peer attempted while below the threshold"
8067 );
8068 }
8069 let health = store
8070 .get_peer_sync_health("hang.example.com", "tm_test")
8071 .await
8072 .unwrap();
8073 assert_eq!(
8074 health.consecutive_failures,
8075 crate::gasp::PEER_QUARANTINE_THRESHOLD
8076 );
8077
8078 // Next tick: QUARANTINED — the factory is never consulted, no error
8079 // is emitted, and the attempt count does not grow (a skip is not an
8080 // attempt, so the re-probe window keeps aging).
8081 let result = engine.start_gasp_sync().await.unwrap();
8082 assert!(result.topics_synced["tm_test"].errors.is_empty());
8083 assert_eq!(
8084 created.borrow().len() as u64,
8085 crate::gasp::PEER_QUARANTINE_THRESHOLD,
8086 "quarantined peer must be skipped"
8087 );
8088
8089 // Age past the re-probe window: exactly one fresh probe is allowed.
8090 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
8091 engine.start_gasp_sync().await.unwrap();
8092 assert_eq!(
8093 created.borrow().len() as u64,
8094 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
8095 "re-probe attempted after the window"
8096 );
8097 // The probe failed again → failure count kept growing → re-armed.
8098 let health = store
8099 .get_peer_sync_health("hang.example.com", "tm_test")
8100 .await
8101 .unwrap();
8102 assert_eq!(
8103 health.consecutive_failures,
8104 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
8105 );
8106 let result = engine.start_gasp_sync().await.unwrap();
8107 assert!(
8108 result.topics_synced["tm_test"].errors.is_empty(),
8109 "re-quarantined"
8110 );
8111
8112 // The peer comes back to life: age to the next re-probe, then answer
8113 // like a healthy peer → SUCCESS resets the count to 0 (full
8114 // re-admission — quarantine is never a deletion).
8115 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
8116 // Swap the factory for one that serves the healthy mock for this URL.
8117 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
8118 let result = engine.start_gasp_sync().await.unwrap();
8119 assert!(result.topics_synced["tm_test"].errors.is_empty());
8120 let health = store
8121 .get_peer_sync_health("hang.example.com", "tm_test")
8122 .await
8123 .unwrap();
8124 assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
8125 }
8126
8127 // ── Mock ARC Broadcaster ─────────────────────────────────────────
8128
8129 use crate::broadcaster::ArcBroadcaster;
8130
8131 struct MockArcBroadcaster {
8132 calls: Arc<Mutex<Vec<String>>>,
8133 should_fail: bool,
8134 }
8135
8136 impl MockArcBroadcaster {
8137 fn new() -> Self {
8138 Self {
8139 calls: Arc::new(Mutex::new(Vec::new())),
8140 should_fail: false,
8141 }
8142 }
8143
8144 fn failing() -> Self {
8145 Self {
8146 calls: Arc::new(Mutex::new(Vec::new())),
8147 should_fail: true,
8148 }
8149 }
8150 }
8151
8152 #[async_trait(?Send)]
8153 impl ArcBroadcaster for MockArcBroadcaster {
8154 async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
8155 self.calls.lock().unwrap().push(raw_tx_hex.to_string());
8156 if self.should_fail {
8157 Err("mock ARC failure".to_string())
8158 } else {
8159 Ok("mock_txid_from_arc".to_string())
8160 }
8161 }
8162 }
8163
8164 /// Create engine with an ARC broadcaster (and optionally a SHIP broadcaster).
8165 fn make_engine_with_arc(
8166 admit_indices: Vec<u32>,
8167 arc: MockArcBroadcaster,
8168 ) -> (Engine, Arc<Mutex<Vec<String>>>) {
8169 let arc_calls = arc.calls.clone();
8170
8171 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8172 managers.insert(
8173 "tm_test".to_string(),
8174 Box::new(MockTopicManager::admitting(admit_indices)),
8175 );
8176
8177 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8178 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
8179
8180 let storage = Box::new(MemoryStorage::new());
8181
8182 let engine = Engine::with_all(
8183 managers,
8184 lookup_services,
8185 storage,
8186 None,
8187 None,
8188 Some(Box::new(arc)),
8189 None,
8190 EngineConfig::default(),
8191 );
8192
8193 (engine, arc_calls)
8194 }
8195
8196 // ── ARC Broadcaster Tests ────────────────────────────────────────
8197
8198 #[tokio::test]
8199 async fn test_arc_broadcaster_called_on_current_tx() {
8200 let arc = MockArcBroadcaster::new();
8201 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
8202
8203 let beef = test_tagged_beef(vec!["tm_test"]);
8204 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8205
8206 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8207
8208 let calls = arc_calls.lock().unwrap();
8209 assert_eq!(
8210 calls.len(),
8211 1,
8212 "ARC broadcaster should be called once for CurrentTx"
8213 );
8214 assert!(
8215 !calls[0].is_empty(),
8216 "ARC should receive non-empty raw tx hex"
8217 );
8218 }
8219
8220 #[tokio::test]
8221 async fn test_arc_broadcaster_not_called_on_historical_tx() {
8222 let arc = MockArcBroadcaster::new();
8223 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
8224
8225 let beef = test_tagged_beef(vec!["tm_test"]);
8226 engine
8227 .submit(&beef, SubmitMode::HistoricalTx)
8228 .await
8229 .unwrap();
8230
8231 assert_eq!(
8232 arc_calls.lock().unwrap().len(),
8233 0,
8234 "ARC broadcaster should NOT be called for HistoricalTx"
8235 );
8236 }
8237
8238 #[tokio::test]
8239 async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
8240 let arc = MockArcBroadcaster::new();
8241 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
8242
8243 let beef = test_tagged_beef(vec!["tm_test"]);
8244 engine
8245 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
8246 .await
8247 .unwrap();
8248
8249 assert_eq!(
8250 arc_calls.lock().unwrap().len(),
8251 0,
8252 "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
8253 );
8254 }
8255
8256 #[tokio::test]
8257 async fn test_arc_broadcast_failure_does_not_fail_submit() {
8258 let arc = MockArcBroadcaster::failing();
8259 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
8260
8261 let beef = test_tagged_beef(vec!["tm_test"]);
8262 // submit should succeed even when ARC fails
8263 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8264
8265 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8266
8267 // ARC was called (and failed), but submit still succeeded
8268 assert_eq!(
8269 arc_calls.lock().unwrap().len(),
8270 1,
8271 "ARC broadcaster should be attempted even if it will fail"
8272 );
8273 }
8274
8275 #[tokio::test]
8276 async fn test_arc_broadcaster_not_present_still_works() {
8277 // Engine without ARC broadcaster — should work fine
8278 let engine = make_engine(vec![0]);
8279
8280 let beef = test_tagged_beef(vec!["tm_test"]);
8281 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8282
8283 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8284 }
8285
8286 // ── Sync advertisements URL validation ─────────────────────────────
8287
8288 /// Mock Advertiser that tracks created advertisements via shared state.
8289 struct TrackingAdvertiser {
8290 created: Arc<Mutex<Vec<AdvertisementData>>>,
8291 existing_ship: Vec<Advertisement>,
8292 existing_slap: Vec<Advertisement>,
8293 /// Topics the returned TaggedBEEF is tagged with — a topic with no
8294 /// registered manager forces the LOCAL submit to fail (#320 3a);
8295 /// several topics exercise independent per-topic admission (D1).
8296 tag_topics: Vec<String>,
8297 /// Simulate a create_advertisements failure (#320 3a).
8298 create_fails: bool,
8299 /// Simulate a find_all_advertisements failure (#320 M2).
8300 find_fails: bool,
8301 /// Return the v1 no-op EMPTY TaggedBEEF from revoke (#320 L2).
8302 revoke_empty: bool,
8303 }
8304
8305 impl TrackingAdvertiser {
8306 fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
8307 let created = Arc::new(Mutex::new(Vec::new()));
8308 (
8309 Self {
8310 created: created.clone(),
8311 existing_ship: vec![],
8312 existing_slap: vec![],
8313 tag_topics: vec!["tm_test".to_string()],
8314 create_fails: false,
8315 find_fails: false,
8316 revoke_empty: false,
8317 },
8318 created,
8319 )
8320 }
8321 }
8322
8323 use crate::advertiser::{Advertiser, AdvertiserError};
8324
8325 #[async_trait(?Send)]
8326 impl Advertiser for TrackingAdvertiser {
8327 async fn create_advertisements(
8328 &self,
8329 ads: &[AdvertisementData],
8330 ) -> Result<TaggedBEEF, AdvertiserError> {
8331 if self.create_fails {
8332 return Err(AdvertiserError::CreationFailed(
8333 "mock create refused".into(),
8334 ));
8335 }
8336 self.created.lock().unwrap().extend(ads.iter().cloned());
8337 Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
8338 }
8339
8340 async fn find_all_advertisements(
8341 &self,
8342 protocol: Protocol,
8343 ) -> Result<Vec<Advertisement>, AdvertiserError> {
8344 if self.find_fails {
8345 return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
8346 }
8347 match protocol {
8348 Protocol::Ship => Ok(self.existing_ship.clone()),
8349 Protocol::Slap => Ok(self.existing_slap.clone()),
8350 }
8351 }
8352
8353 async fn revoke_advertisements(
8354 &self,
8355 _ads: &[Advertisement],
8356 ) -> Result<TaggedBEEF, AdvertiserError> {
8357 if self.revoke_empty {
8358 return Ok(TaggedBEEF::new(vec![], vec![]));
8359 }
8360 Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
8361 }
8362
8363 fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
8364 None
8365 }
8366 }
8367
8368 /// TS: "Sync advertisements URL validation"
8369 /// sync_advertisements should be a no-op when hosting_url is empty string,
8370 /// and should skip suppressed topics (tm_ship/tm_slap) when configured.
8371 #[tokio::test]
8372 async fn test_sync_advertisements_url_validation() {
8373 // Case 1: Empty hosting URL — should return early without creating ads
8374 let (adv, created) = TrackingAdvertiser::new();
8375
8376 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8377 managers.insert(
8378 "tm_test".to_string(),
8379 Box::new(MockTopicManager::admitting(vec![0])),
8380 );
8381
8382 let engine = Engine::new(
8383 managers,
8384 HashMap::new(),
8385 Box::new(MemoryStorage::new()),
8386 Some(Box::new(adv)),
8387 EngineConfig {
8388 hosting_url: Some(String::new()), // empty string — invalid
8389 ..Default::default()
8390 },
8391 );
8392
8393 engine.sync_advertisements().await.unwrap();
8394 assert!(
8395 created.lock().unwrap().is_empty(),
8396 "Empty hosting URL should skip advertisement creation"
8397 );
8398
8399 // Case 2: Valid hosting URL — should create SHIP advertisements for non-suppressed topics
8400 let (adv2, created2) = TrackingAdvertiser::new();
8401
8402 let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8403 managers2.insert(
8404 "tm_test".to_string(),
8405 Box::new(MockTopicManager::admitting(vec![0])),
8406 );
8407 // Also register tm_ship to verify it gets suppressed
8408 managers2.insert(
8409 "tm_ship".to_string(),
8410 Box::new(MockTopicManager::admitting(vec![])),
8411 );
8412
8413 let engine2 = Engine::new(
8414 managers2,
8415 HashMap::new(),
8416 Box::new(MemoryStorage::new()),
8417 Some(Box::new(adv2)),
8418 EngineConfig {
8419 hosting_url: Some("https://valid.example.com".to_string()),
8420 suppress_default_sync_advertisements: true,
8421 ..Default::default()
8422 },
8423 );
8424
8425 engine2.sync_advertisements().await.unwrap();
8426 let ads = created2.lock().unwrap();
8427 // tm_ship should be suppressed, only tm_test should get an advertisement
8428 assert!(
8429 ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
8430 "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
8431 );
8432 assert!(
8433 ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
8434 "tm_test should get a SHIP advertisement"
8435 );
8436 }
8437
8438 // ── sync_advertisements report (bsv-low #320 defect 3a) ────────────
8439 //
8440 // Pre-#320 the engine returned `Ok(())` unconditionally: a failed
8441 // create or a failed LOCAL submit was `error!`-logged and invisible to
8442 // the caller — the admin route said `success` while the node's own
8443 // ls_ship/ls_slap never gained its ads, so every cycle re-created the
8444 // whole set. These cells pin the report contract.
8445
8446 fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
8447 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8448 managers.insert(
8449 "tm_test".to_string(),
8450 Box::new(MockTopicManager::admitting(vec![0])),
8451 );
8452 Engine::new(
8453 managers,
8454 HashMap::new(),
8455 Box::new(MemoryStorage::new()),
8456 Some(Box::new(adv)),
8457 EngineConfig {
8458 hosting_url: Some("https://valid.example.com".to_string()),
8459 suppress_default_sync_advertisements: true,
8460 ..Default::default()
8461 },
8462 )
8463 }
8464
8465 #[tokio::test]
8466 async fn sync_report_counts_admitted_outputs_on_success() {
8467 let (adv, _) = TrackingAdvertiser::new();
8468 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
8469 assert!(report.ok(), "clean run must report ok: {report:?}");
8470 assert_eq!(report.to_create, 1, "tm_test was missing");
8471 assert_eq!(
8472 report.admitted.get("tm_test"),
8473 Some(&1),
8474 "the STEAK's admitted count must be surfaced: {report:?}"
8475 );
8476 }
8477
8478 #[tokio::test]
8479 async fn sync_report_surfaces_local_submit_failure() {
8480 // The TaggedBEEF comes back tagged for a topic with no registered
8481 // manager → the LOCAL submit fails. That failure must be in the
8482 // report, not swallowed (the live silent-failure shape).
8483 let (mut adv, _) = TrackingAdvertiser::new();
8484 adv.tag_topics = vec!["tm_unregistered".to_string()];
8485 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
8486 assert!(!report.ok(), "a failed local submit must not report ok");
8487 assert_eq!(report.to_create, 1);
8488 let submit_error = report.submit_error.as_deref().unwrap_or_default();
8489 assert!(
8490 submit_error.contains("tm_unregistered"),
8491 "verbatim engine error expected, got: {submit_error}"
8492 );
8493 assert!(report.admitted.is_empty());
8494 }
8495
8496 #[tokio::test]
8497 async fn sync_report_surfaces_create_failure() {
8498 let (mut adv, _) = TrackingAdvertiser::new();
8499 adv.create_fails = true;
8500 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
8501 assert!(!report.ok());
8502 assert!(
8503 report
8504 .create_error
8505 .as_deref()
8506 .unwrap_or_default()
8507 .contains("mock create refused"),
8508 "{report:?}"
8509 );
8510 assert!(report.submit_error.is_none(), "create failed before submit");
8511 }
8512
8513 #[tokio::test]
8514 async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
8515 // A node whose own storage already lists its ads must attempt ZERO
8516 // creates — the convergence direction of #320 defect 3 (live, the
8517 // missing self-admission made every cycle re-create all 17 ads).
8518 let (mut adv, created) = TrackingAdvertiser::new();
8519 adv.existing_ship = vec![Advertisement {
8520 protocol: Protocol::Ship,
8521 identity_key: "02aa".to_string(),
8522 domain: "https://valid.example.com".to_string(),
8523 topic_or_service: "tm_test".to_string(),
8524 beef: None,
8525 output_index: None,
8526 }];
8527 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
8528 assert!(report.ok(), "{report:?}");
8529 assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
8530 assert!(created.lock().unwrap().is_empty());
8531 assert!(
8532 report.effective(),
8533 "converged-noop is the one zero-admit shape that IS success"
8534 );
8535 }
8536
8537 #[tokio::test]
8538 async fn sync_report_zero_admit_is_ok_but_not_effective() {
8539 // #320 M1: the local submit SUCCEEDS but the topic manager admits
8540 // ZERO outputs — a non-error refusal. The ads never enter our own
8541 // ls_ship/ls_slap, so the diff re-creates (re-pays) the full set
8542 // next cycle. `ok()` (errors-only) stays true; `effective()` must
8543 // refuse, and the route keys success on `effective()`.
8544 let (adv, _) = TrackingAdvertiser::new();
8545 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8546 managers.insert(
8547 "tm_test".to_string(),
8548 Box::new(MockTopicManager::admitting(vec![])), // refuses everything
8549 );
8550 let engine = Engine::new(
8551 managers,
8552 HashMap::new(),
8553 Box::new(MemoryStorage::new()),
8554 Some(Box::new(adv)),
8555 EngineConfig {
8556 hosting_url: Some("https://valid.example.com".to_string()),
8557 suppress_default_sync_advertisements: true,
8558 ..Default::default()
8559 },
8560 );
8561 let report = engine.sync_advertisements().await.unwrap();
8562 assert!(report.ok(), "no ERROR occurred: {report:?}");
8563 assert_eq!(report.to_create, 1);
8564 assert_eq!(report.admitted_total(), 0, "{report:?}");
8565 assert!(
8566 !report.effective(),
8567 "zero admitted on a non-empty create must not read as success: {report:?}"
8568 );
8569 }
8570
8571 #[tokio::test]
8572 async fn sync_report_partial_admit_is_not_effective() {
8573 // Delta D1: TWO topics in one submitted tx, per-topic admission
8574 // independent — tm_test admits its output, tm_refuse refuses all.
8575 // admitted_total (1) > 0 but < to_create (2): the refused ads
8576 // would be re-created (re-paid) every cycle, so this must NOT
8577 // read as success.
8578 let (mut adv, _) = TrackingAdvertiser::new();
8579 adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
8580 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8581 managers.insert(
8582 "tm_test".to_string(),
8583 Box::new(MockTopicManager::admitting(vec![0])),
8584 );
8585 managers.insert(
8586 "tm_refuse".to_string(),
8587 Box::new(MockTopicManager::admitting(vec![])), // refuses everything
8588 );
8589 let engine = Engine::new(
8590 managers,
8591 HashMap::new(),
8592 Box::new(MemoryStorage::new()),
8593 Some(Box::new(adv)),
8594 EngineConfig {
8595 hosting_url: Some("https://valid.example.com".to_string()),
8596 suppress_default_sync_advertisements: true,
8597 ..Default::default()
8598 },
8599 );
8600 let report = engine.sync_advertisements().await.unwrap();
8601 assert!(report.ok(), "no ERROR occurred: {report:?}");
8602 assert_eq!(report.to_create, 2, "{report:?}");
8603 assert_eq!(report.admitted_total(), 1, "{report:?}");
8604 assert!(
8605 !report.effective(),
8606 "a partial admit must not read as success: {report:?}"
8607 );
8608 }
8609
8610 #[tokio::test]
8611 async fn sync_report_lookup_failure_refuses_creation() {
8612 // #320 M2: a transient read failure must never become "current ads
8613 // = none" and re-create (re-pay) the whole set — creation is
8614 // REFUSED and the failure lands in the report.
8615 let (mut adv, created) = TrackingAdvertiser::new();
8616 adv.find_fails = true;
8617 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
8618 assert!(!report.ok(), "{report:?}");
8619 assert!(!report.effective());
8620 assert!(
8621 report
8622 .lookup_error
8623 .as_deref()
8624 .unwrap_or_default()
8625 .contains("mock lookup refused"),
8626 "{report:?}"
8627 );
8628 assert_eq!(report.to_create, 0, "creation refused, not attempted");
8629 assert!(
8630 created.lock().unwrap().is_empty(),
8631 "no create call on a blind read"
8632 );
8633 assert!(report.submit_error.is_none());
8634 }
8635
8636 #[tokio::test]
8637 async fn sync_report_counts_skipped_revocations() {
8638 // #320 L2: `to_revoke: N` with a v1 no-op advertiser must be
8639 // distinguishable from a revoke that actually ran.
8640 let (mut adv, _) = TrackingAdvertiser::new();
8641 adv.revoke_empty = true;
8642 adv.existing_ship = vec![
8643 Advertisement {
8644 protocol: Protocol::Ship,
8645 identity_key: "02aa".to_string(),
8646 domain: "https://valid.example.com".to_string(),
8647 topic_or_service: "tm_test".to_string(),
8648 beef: None,
8649 output_index: None,
8650 },
8651 Advertisement {
8652 protocol: Protocol::Ship,
8653 identity_key: "02aa".to_string(),
8654 domain: "https://valid.example.com".to_string(),
8655 topic_or_service: "tm_stale".to_string(), // no longer configured
8656 beef: None,
8657 output_index: None,
8658 },
8659 ];
8660 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
8661 assert!(report.ok(), "{report:?}");
8662 assert_eq!(report.to_revoke, 1, "tm_stale is stale");
8663 assert_eq!(
8664 report.revoke_skipped, 1,
8665 "the v1 no-op must be visible, not silent: {report:?}"
8666 );
8667 assert!(report.revoke_error.is_none());
8668 assert!(report.revoke_submit_error.is_none());
8669 }
8670
8671 // ── complete_missing_proofs (#130) ─────────────────────────────────
8672
8673 use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
8674 use std::cell::Cell;
8675
8676 /// A single raw mainnet tx (no proof) — used to build a proofless BEEF.
8677 const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
8678
8679 /// Build a proofless single-tx BEEF + return `(beef_bytes, txid)`.
8680 fn proofless_beef() -> (Vec<u8>, String) {
8681 use bsv_rs::transaction::{Beef, Transaction};
8682 let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
8683 let txid = tx.id();
8684 let mut beef = Beef::new();
8685 beef.merge_transaction(tx);
8686 (beef.to_binary(), txid)
8687 }
8688
8689 // ── #284: stitching a proof must never rebuild the stored BEEF ─────────
8690
8691 /// The corruption shape observed live on rust-beta: a stored BEEF whose
8692 /// SUBJECT is not the last tx in wire order. The old rewrite went through
8693 /// `Transaction::from_beef(bytes, None)`, whose None arm picks
8694 /// `txs.last()` — the PARENT here — and `to_beef(true)` then serialized the
8695 /// parent's world, dropping the subject from its own row. TEST_BEEF_HEX is
8696 /// exactly a two-tx chain (parent + child spending it), so re-encoding it
8697 /// child-first reproduces the trigger byte-for-byte.
8698 fn subject_not_last_beef() -> (Vec<u8>, String, String) {
8699 use bsv_rs::transaction::Beef;
8700 let parsed = Beef::from_binary(&test_beef()).unwrap();
8701 let parent = parsed.txs[0].tx().unwrap().clone();
8702 let child = parsed.txs[1].tx().unwrap().clone();
8703 let (parent_id, child_id) = (parent.id(), child.id());
8704 assert_ne!(parent_id, child_id);
8705
8706 // Serialize via to_writer, NOT to_binary: to_binary re-sorts
8707 // parents-first (subject last), which is precisely why bsv_rs's own
8708 // output never triggers the bug. The corrupt rows held WALLET
8709 // serialized bytes stored verbatim at admit time, whose wire order is
8710 // whatever the wallet emitted — model that by writing the child first.
8711 let mut beef = Beef::new();
8712 beef.merge_transaction(child); // subject FIRST — the trigger
8713 beef.merge_transaction(parent);
8714 let mut w = bsv_rs::primitives::encoding::Writer::new();
8715 beef.to_writer(&mut w);
8716 let bytes = w.into_bytes();
8717
8718 // The premise the whole test rests on: wire order preserved, subject
8719 // genuinely not last.
8720 let reread = Beef::from_binary(&bytes).unwrap();
8721 assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
8722 (bytes, child_id, parent_id)
8723 }
8724
8725 #[test]
8726 fn stitch_preserves_the_subject_when_it_is_not_last() {
8727 use bsv_rs::transaction::{Beef, MerklePath};
8728 let (stored, child_id, parent_id) = subject_not_last_beef();
8729 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
8730
8731 let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
8732 .expect("stitch must succeed on a beef that contains the txid");
8733
8734 let after = Beef::from_binary(&out).unwrap();
8735 assert!(
8736 after.find_txid(&child_id).is_some(),
8737 "the SUBJECT must survive its own proof-completion"
8738 );
8739 assert!(
8740 after.find_txid(&parent_id).is_some(),
8741 "the ancestry must survive too"
8742 );
8743 assert!(
8744 after.find_bump(&child_id).is_some(),
8745 "the stitched bump must prove the subject"
8746 );
8747 assert!(
8748 after.find_bump(&parent_id).is_none(),
8749 "the parent must NOT be claimed by the child's bump"
8750 );
8751 }
8752
8753 /// The old path, reproduced against the same input, to pin WHY the new one
8754 /// exists: it demonstrably drops the subject. If bsv_rs ever changes
8755 /// `from_beef(_, None)` to honor the subject, this starts failing and the
8756 /// comment trail can be revisited — until then it documents the hazard.
8757 #[test]
8758 fn the_old_round_trip_demonstrably_dropped_the_subject() {
8759 use bsv_rs::transaction::{Beef, Transaction};
8760 let (stored, child_id, _parent_id) = subject_not_last_beef();
8761 let tx = Transaction::from_beef(&stored, None).unwrap();
8762 assert_ne!(
8763 tx.id(),
8764 child_id,
8765 "from_beef(None) picks txs.last(), not the subject — the trigger"
8766 );
8767 let rebuilt = tx.to_beef(true).unwrap();
8768 let reparsed = Beef::from_binary(&rebuilt).unwrap();
8769 assert!(
8770 reparsed.find_txid(&child_id).is_none(),
8771 "the rebuild loses the subject — exactly the #284 artifact"
8772 );
8773 }
8774
8775 /// bsv-low M19 R2 round 3 (review HIGH-1): a stored BEEF whose SUBJECT
8776 /// sits on a stale (orphan) bump at height H, re-stitched with a
8777 /// same-height DIFFERENT-root proof, must serialize with the subject on
8778 /// the NEW bump and the orphan bump dropped — otherwise the hop leg
8779 /// churns and re-latches the orphan proof forever.
8780 #[test]
8781 fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
8782 use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
8783 // subject spends a real parent; both are raw in the BEEF
8784 let parent = Transaction::from_hex(RAW_PARENT).unwrap();
8785 let parent_id = parent.id();
8786 let subject = child_of(&parent_id, 0);
8787 let subject_id = subject.id();
8788 // the ORPHAN bump: a two-leaf block at H so its root is NOT the txid,
8789 // and it differs from the canonical block's root
8790 let orphan = MerklePath::new_unchecked(
8791 965_771,
8792 vec![vec![
8793 MerklePathLeaf::new_txid(0, subject_id.clone()),
8794 MerklePathLeaf::new(1, "aa".repeat(32)),
8795 ]],
8796 )
8797 .unwrap();
8798 let mut beef = Beef::new();
8799 let bi = beef.merge_bump(orphan.clone());
8800 beef.merge_raw_tx(parent.to_binary(), None);
8801 beef.merge_raw_tx(subject.to_binary(), Some(bi));
8802 let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
8803 let stored = beef.to_binary();
8804 assert_eq!(
8805 Beef::from_binary(&stored)
8806 .unwrap()
8807 .find_bump(&subject_id)
8808 .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
8809 Some(orphan_root.clone())
8810 );
8811 // the canonical proof at the SAME height, a DIFFERENT root
8812 let canonical = MerklePath::new_unchecked(
8813 965_771,
8814 vec![vec![
8815 MerklePathLeaf::new_txid(0, subject_id.clone()),
8816 MerklePathLeaf::new(1, "bb".repeat(32)),
8817 ]],
8818 )
8819 .unwrap();
8820 let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
8821 assert_ne!(
8822 canonical_root, orphan_root,
8823 "the reorg shape: same height, new root"
8824 );
8825 let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
8826 let after = Beef::from_binary(&out).unwrap();
8827 // the subject's OWN bump is the canonical one
8828 let own = after
8829 .find_txid(&subject_id)
8830 .and_then(BeefTx::bump_index)
8831 .and_then(|bi| after.bumps.get(bi))
8832 .unwrap();
8833 assert_eq!(
8834 own.compute_root(Some(&subject_id)).unwrap(),
8835 canonical_root,
8836 "the subject re-anchored to the new bump"
8837 );
8838 // the orphan bump is gone (only the canonical one remains)
8839 assert_eq!(
8840 after.bumps.len(),
8841 1,
8842 "the unreferenced orphan bump was dropped"
8843 );
8844 // and a reader that uses the tx's OWN bump sees canonical, never orphan
8845 assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
8846 }
8847
8848 #[test]
8849 fn stitch_refuses_a_beef_that_lacks_the_txid() {
8850 use bsv_rs::transaction::MerklePath;
8851 let (stored, _c, _p) = subject_not_last_beef();
8852 let foreign = "aa".repeat(32);
8853 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
8854 assert!(
8855 Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
8856 "no txid in the beef -> no write, never garbage"
8857 );
8858 }
8859
8860 /// A real mainnet raw tx, the unproven parent for the re-anchor pin.
8861 const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
8862
8863 /// A minimal raw tx spending `source:vout` with one OP_TRUE output.
8864 fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
8865 let mut sx = String::from("0100000001");
8866 let mut prev = hex::decode(source_txid).unwrap();
8867 prev.reverse();
8868 sx.push_str(&hex::encode(prev));
8869 sx.push_str(&hex::encode(vout.to_le_bytes()));
8870 sx.push_str("00ffffffff01");
8871 sx.push_str(&hex::encode(1000u64.to_le_bytes()));
8872 sx.push_str("015100000000");
8873 bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
8874 }
8875
8876 /// A minimal valid single-leaf BUMP hex proving `txid` at `height`.
8877 fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
8878 use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
8879 let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
8880 MerklePath::new_unchecked(height, vec![vec![leaf]])
8881 .unwrap()
8882 .to_hex()
8883 }
8884
8885 /// Mock fetcher: returns a configured proof (or none) for any txid, and
8886 /// counts how many times it was asked.
8887 struct StubFetcher {
8888 proof: Option<String>,
8889 calls: Cell<u32>,
8890 }
8891
8892 #[async_trait(?Send)]
8893 impl AncestorFetcher for StubFetcher {
8894 async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
8895 self.calls.set(self.calls.get() + 1);
8896 Ok(FetchedAncestor {
8897 raw_tx: PROOFLESS_RAW_TX.to_string(),
8898 proof: self.proof.clone(),
8899 })
8900 }
8901
8902 /// Models a fetcher whose header source CONFIRMS a stored bump — so the
8903 /// already-structurally-proven re-verify gate (the window-clog path)
8904 /// latches without a re-fetch, exactly as before the #192/#193 hardening.
8905 /// (`verified_proof_for` is left at its default: it delegates to
8906 /// `fetch_ancestor` above, so the `calls` counter still tracks fetches.)
8907 async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
8908 true
8909 }
8910 }
8911
8912 /// Build an engine over a storage holding one proofless output, with an
8913 /// optional ancestor fetcher.
8914 async fn engine_with_proofless_output(
8915 fetcher: Option<std::rc::Rc<StubFetcher>>,
8916 ) -> (Engine, String) {
8917 let (beef, txid) = proofless_beef();
8918 let storage = MemoryStorage::new();
8919 storage
8920 .insert_output(&Output {
8921 txid: txid.clone(),
8922 output_index: 0,
8923 output_script: vec![0x76],
8924 satoshis: 1_000_000_000,
8925 topic: "Hello".to_string(),
8926 spent: false,
8927 outputs_consumed: vec![],
8928 consumed_by: vec![],
8929 beef: Some(beef),
8930 block_height: None,
8931 score: Some(1.0),
8932 })
8933 .await
8934 .unwrap();
8935
8936 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
8937 managers.insert(
8938 "Hello".into(),
8939 Box::new(MockTopicManager::admitting(vec![0])),
8940 );
8941 let mut engine = Engine::new(
8942 managers,
8943 HashMap::new(),
8944 Box::new(storage),
8945 None,
8946 EngineConfig::default(),
8947 );
8948 if let Some(f) = fetcher {
8949 engine.set_ancestor_fetcher(f);
8950 }
8951 (engine, txid)
8952 }
8953
8954 #[tokio::test]
8955 async fn complete_missing_proofs_noop_without_fetcher() {
8956 // No ancestor fetcher configured → pure no-op (production default).
8957 let (engine, _txid) = engine_with_proofless_output(None).await;
8958 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
8959 assert_eq!(summary, ProofCompletionSummary::default());
8960 }
8961
8962 #[tokio::test]
8963 async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
8964 let (_beef, txid) = proofless_beef();
8965 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
8966 let fetcher = std::rc::Rc::new(StubFetcher {
8967 proof: Some(proof_hex),
8968 calls: Cell::new(0),
8969 });
8970 let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
8971
8972 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
8973 assert_eq!(summary.scanned, 1);
8974 assert_eq!(summary.proofless, 1);
8975 assert_eq!(
8976 summary.completed, 1,
8977 "the proofless BEEF should be completed"
8978 );
8979 assert_eq!(fetcher.calls.get(), 1);
8980
8981 // The stored BEEF now carries a proof for the target tx + the output
8982 // got its block height.
8983 let out = engine
8984 .storage()
8985 .find_output(&txid, 0, Some("Hello"), None, true)
8986 .await
8987 .unwrap()
8988 .unwrap();
8989 assert_eq!(out.block_height, Some(850_000));
8990 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
8991 assert!(
8992 beef.find_txid(&txid)
8993 .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
8994 "completed BEEF must prove the target tx"
8995 );
8996
8997 // A second pass is a no-op: the tx is now proven, so it is not counted
8998 // proofless and the fetcher is not asked again.
8999 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
9000 assert_eq!(summary2.proofless, 0);
9001 assert_eq!(summary2.completed, 0);
9002 assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
9003 }
9004
9005 #[tokio::test]
9006 async fn complete_missing_proofs_skips_unconfirmed() {
9007 // Fetcher reachable but the tx is unmined (no BUMP) → skipped, not
9008 // errored, and counted still_unconfirmed for retry.
9009 let fetcher = std::rc::Rc::new(StubFetcher {
9010 proof: None,
9011 calls: Cell::new(0),
9012 });
9013 let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
9014 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
9015 assert_eq!(summary.proofless, 1);
9016 assert_eq!(summary.completed, 0);
9017 assert_eq!(summary.still_unconfirmed, 1);
9018 assert_eq!(fetcher.calls.get(), 1);
9019 }
9020
9021 /// Build an engine like [`engine_with_proofless_output`] but keep a shared
9022 /// handle to the `MemoryStorage` (via the `Rc<T: Storage>` delegation) so
9023 /// the test can advance its deterministic clock — the push-primary
9024 /// backstop age-gate tests (#228) need to age rows without sleeping.
9025 async fn engine_with_proofless_output_shared(
9026 fetcher: Option<std::rc::Rc<StubFetcher>>,
9027 ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
9028 let (beef, txid) = proofless_beef();
9029 let storage = std::rc::Rc::new(MemoryStorage::new());
9030 storage
9031 .insert_output(&Output {
9032 txid: txid.clone(),
9033 output_index: 0,
9034 output_script: vec![0x76],
9035 satoshis: 1_000_000_000,
9036 topic: "Hello".to_string(),
9037 spent: false,
9038 outputs_consumed: vec![],
9039 consumed_by: vec![],
9040 beef: Some(beef),
9041 block_height: None,
9042 score: Some(1.0),
9043 })
9044 .await
9045 .unwrap();
9046
9047 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9048 managers.insert(
9049 "Hello".into(),
9050 Box::new(MockTopicManager::admitting(vec![0])),
9051 );
9052 let mut engine = Engine::new(
9053 managers,
9054 HashMap::new(),
9055 Box::new(storage.clone()),
9056 None,
9057 EngineConfig::default(),
9058 );
9059 if let Some(f) = fetcher {
9060 engine.set_ancestor_fetcher(f);
9061 }
9062 (engine, txid, storage)
9063 }
9064
9065 #[tokio::test]
9066 async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
9067 // Push-primary backstop (#228): a FRESHLY stored proofless tx must NOT
9068 // be polled — its proof is expected via /arc-ingest at push speed. The
9069 // gated pass scans nothing and never asks the fetcher.
9070 let proof_hex = {
9071 let (_beef, txid) = proofless_beef();
9072 single_leaf_bump_hex(&txid, 850_000)
9073 };
9074 let fetcher = std::rc::Rc::new(StubFetcher {
9075 proof: Some(proof_hex),
9076 calls: Cell::new(0),
9077 });
9078 let (engine, _txid, storage) =
9079 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
9080
9081 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
9082 assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
9083 assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
9084
9085 // WEBHOOK-OUTAGE DEGRADATION: no push ever arrives. Once the row is
9086 // older than the gate, the SAME backstop pass polls and completes it
9087 // exactly as the pre-#228 behaviour — degradation is to polling,
9088 // never to nothing.
9089 storage.advance_clock(1800);
9090 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
9091 assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
9092 assert_eq!(summary.completed, 1, "the backstop completes the proof");
9093 assert_eq!(fetcher.calls.get(), 1);
9094 }
9095
9096 #[tokio::test]
9097 async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
9098 // Pushed-proof-then-chaser-skips (#228): a proof arriving via the real
9099 // /arc-ingest producer path (`handle_new_merkle_proof` → the
9100 // `update_transaction_beef` stitch, which latches `has_proof`) drops
9101 // the tx out of the poll candidate set entirely — even after the age
9102 // gate opens, the chaser never asks the fetcher about it.
9103 let fetcher = std::rc::Rc::new(StubFetcher {
9104 proof: None,
9105 calls: Cell::new(0),
9106 });
9107 let (engine, txid, storage) =
9108 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
9109
9110 // The push lands (arc-ingest calls exactly this after its own
9111 // chaintracks verify).
9112 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
9113 engine
9114 .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
9115 .await
9116 .unwrap();
9117
9118 // Age the row well past the backstop gate: still skipped — the pushed
9119 // proof, not the age, is what removes it from the chaser's world.
9120 storage.advance_clock(1_000_000);
9121 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
9122 assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
9123 assert_eq!(summary.completed, 0);
9124 assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
9125 }
9126
9127 #[tokio::test]
9128 async fn complete_missing_proofs_respects_limit() {
9129 // Two proofless txs, limit 1 → only one row is scanned this tick.
9130 let (beef1, txid1) = proofless_beef();
9131 // A second distinct proofless tx (different raw tx → different txid).
9132 let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
9133 let (beef2, txid2) = {
9134 use bsv_rs::transaction::{Beef, Transaction};
9135 let tx = Transaction::from_hex(raw2).unwrap();
9136 let id = tx.id();
9137 let mut b = Beef::new();
9138 b.merge_transaction(tx);
9139 (b.to_binary(), id)
9140 };
9141
9142 let storage = MemoryStorage::new();
9143 for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
9144 storage
9145 .insert_output(&Output {
9146 txid: txid.clone(),
9147 output_index: 0,
9148 output_script: vec![0x76],
9149 satoshis: 1000,
9150 topic: "Hello".to_string(),
9151 spent: false,
9152 outputs_consumed: vec![],
9153 consumed_by: vec![],
9154 beef: Some(beef),
9155 block_height: None,
9156 score: Some(1.0),
9157 })
9158 .await
9159 .unwrap();
9160 }
9161
9162 let fetcher = std::rc::Rc::new(StubFetcher {
9163 proof: None,
9164 calls: Cell::new(0),
9165 });
9166 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9167 managers.insert(
9168 "Hello".into(),
9169 Box::new(MockTopicManager::admitting(vec![0])),
9170 );
9171 let mut engine = Engine::new(
9172 managers,
9173 HashMap::new(),
9174 Box::new(storage),
9175 None,
9176 EngineConfig::default(),
9177 );
9178 engine.set_ancestor_fetcher(fetcher.clone());
9179
9180 let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
9181 assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
9182 }
9183
9184 #[tokio::test]
9185 async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
9186 // Window-clog regression (#130): migration 0010 defaulted EVERY
9187 // existing `transactions` row to has_proof = 0, including rows whose
9188 // stored BEEF already carries a proof (e.g. GASP-synced txs). The
9189 // candidate query (`WHERE has_proof = 0 LIMIT n`) returns those rows
9190 // every tick; the engine skips them but — before this fix — never
9191 // flipped the flag, so they lingered in the bounded window forever and
9192 // starved genuinely-proofless rows behind them. The fix marks each
9193 // already-proven scanned row so it drops out of the NEXT pass.
9194 use bsv_rs::transaction::{Beef, MerklePath, Transaction};
9195
9196 let storage = MemoryStorage::new();
9197
9198 // Seed several ALREADY-PROVEN rows. They are inserted via
9199 // `insert_output`, which (modeling migration 0010's default) does NOT
9200 // set the proof flag — so despite carrying a valid proof they show up
9201 // as has_proof = 0 candidates: the exact clog.
9202 let proven_raws = [
9203 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
9204 "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
9205 ];
9206 let mut proven_ids = Vec::new();
9207 for raw in proven_raws {
9208 let mut tx = Transaction::from_hex(raw).unwrap();
9209 let id = tx.id();
9210 tx.merkle_path =
9211 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
9212 // to_beef(true) carries the merkle proof into the BEEF, so the row
9213 // genuinely IS proven — yet its flag stays 0 (insert path).
9214 let proven_beef = tx.to_beef(true).unwrap();
9215 assert_eq!(
9216 Beef::from_binary(&proven_beef)
9217 .unwrap()
9218 .find_txid(&id)
9219 .map(bsv_rs::transaction::BeefTx::has_proof),
9220 Some(true),
9221 "proven fixture row must carry its proof"
9222 );
9223 proven_ids.push(id.clone());
9224 storage
9225 .insert_output(&Output {
9226 txid: id,
9227 output_index: 0,
9228 output_script: vec![0x76],
9229 satoshis: 1000,
9230 topic: "Hello".to_string(),
9231 spent: false,
9232 outputs_consumed: vec![],
9233 consumed_by: vec![],
9234 beef: Some(proven_beef),
9235 block_height: Some(800_000),
9236 score: Some(1.0),
9237 })
9238 .await
9239 .unwrap();
9240 }
9241
9242 let fetcher = std::rc::Rc::new(StubFetcher {
9243 proof: None,
9244 calls: Cell::new(0),
9245 });
9246 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9247 managers.insert(
9248 "Hello".into(),
9249 Box::new(MockTopicManager::admitting(vec![0])),
9250 );
9251 let mut engine = Engine::new(
9252 managers,
9253 HashMap::new(),
9254 Box::new(storage),
9255 None,
9256 EngineConfig::default(),
9257 );
9258 engine.set_ancestor_fetcher(fetcher.clone());
9259
9260 // First pass over a window covering both proven rows: each is detected
9261 // already-proven and marked. Nothing proofless, nothing fetched.
9262 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
9263 assert_eq!(summary.scanned, proven_ids.len());
9264 assert_eq!(summary.proofless, 0);
9265 assert_eq!(summary.completed, 0);
9266 assert_eq!(
9267 summary.already_proven,
9268 proven_ids.len(),
9269 "every already-proven row is marked this pass"
9270 );
9271 assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
9272
9273 // Window has advanced: the NEXT candidate query no longer returns the
9274 // marked rows, so the pass scans nothing.
9275 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
9276 assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
9277 assert_eq!(summary2.already_proven, 0);
9278 }
9279
9280 #[tokio::test]
9281 async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
9282 // The window-clog in action with a small limit: a genuinely-proofless
9283 // backlog row sits behind already-proven (stale-flag) rows. Each tick
9284 // marks the proven rows it scans (dropping them out), so over a few
9285 // bounded ticks the window advances until the proofless tx is reached
9286 // + completed — instead of being starved forever.
9287 use bsv_rs::transaction::{MerklePath, Transaction};
9288
9289 let storage = MemoryStorage::new();
9290
9291 let proven_raws = [
9292 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
9293 ];
9294 for raw in proven_raws {
9295 let mut tx = Transaction::from_hex(raw).unwrap();
9296 let id = tx.id();
9297 tx.merkle_path =
9298 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
9299 let proven_beef = tx.to_beef(true).unwrap();
9300 storage
9301 .insert_output(&Output {
9302 txid: id,
9303 output_index: 0,
9304 output_script: vec![0x76],
9305 satoshis: 1000,
9306 topic: "Hello".to_string(),
9307 spent: false,
9308 outputs_consumed: vec![],
9309 consumed_by: vec![],
9310 beef: Some(proven_beef),
9311 block_height: Some(800_000),
9312 score: Some(1.0),
9313 })
9314 .await
9315 .unwrap();
9316 }
9317
9318 // The historical proofless tx (the f584f846-style backlog row).
9319 let (proofless, proofless_txid) = proofless_beef();
9320 storage
9321 .insert_output(&Output {
9322 txid: proofless_txid.clone(),
9323 output_index: 0,
9324 output_script: vec![0x76],
9325 satoshis: 1000,
9326 topic: "Hello".to_string(),
9327 spent: false,
9328 outputs_consumed: vec![],
9329 consumed_by: vec![],
9330 beef: Some(proofless),
9331 block_height: None,
9332 score: Some(1.0),
9333 })
9334 .await
9335 .unwrap();
9336
9337 let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
9338 let fetcher = std::rc::Rc::new(StubFetcher {
9339 proof: Some(proof_hex),
9340 calls: Cell::new(0),
9341 });
9342 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9343 managers.insert(
9344 "Hello".into(),
9345 Box::new(MockTopicManager::admitting(vec![0])),
9346 );
9347 let mut engine = Engine::new(
9348 managers,
9349 HashMap::new(),
9350 Box::new(storage),
9351 None,
9352 EngineConfig::default(),
9353 );
9354 engine.set_ancestor_fetcher(fetcher.clone());
9355
9356 // Run bounded ticks (limit 1) until the proofless tx is completed. With
9357 // 2 rows total a couple of ticks suffice; cap iterations defensively.
9358 let mut completed_total = 0;
9359 for _ in 0..5 {
9360 let s = engine.complete_missing_proofs(1, 0).await.unwrap();
9361 completed_total += s.completed;
9362 if completed_total > 0 {
9363 break;
9364 }
9365 }
9366 assert_eq!(
9367 completed_total, 1,
9368 "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
9369 );
9370
9371 let out = engine
9372 .storage()
9373 .find_output(&proofless_txid, 0, Some("Hello"), None, true)
9374 .await
9375 .unwrap()
9376 .unwrap();
9377 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
9378 assert_eq!(
9379 beef.find_txid(&proofless_txid)
9380 .map(bsv_rs::transaction::BeefTx::has_proof),
9381 Some(true),
9382 "the old proofless tx now carries its proof"
9383 );
9384 }
9385
9386 // ── S2 queue-durable admission (bsv-low 2026-08-29): Phase-3 faults ──
9387 //
9388 // The phantom class: under a D1 storm `submit` returned Ok(steak) while
9389 // its writes failed — the ack outlived the admission. These pins hold
9390 // the new contract: a fault is REPORTED, the faulted topic is NOT
9391 // recorded as applied, and a replay of the same bytes re-applies.
9392
9393 use crate::storage::StorageError;
9394 use std::rc::Rc;
9395
9396 /// `MemoryStorage` behind two fault switches. Every method delegates;
9397 /// the two the tests flip model a D1 write fault (`insert_output`) and
9398 /// a D1 read fault (`find_output` — the validation's previous-coin scan
9399 /// AND Phase 3's stale/consumed lookups).
9400 struct FaultingStorage {
9401 inner: MemoryStorage,
9402 fail_insert_output: Cell<bool>,
9403 fail_find_output: Cell<bool>,
9404 }
9405
9406 impl FaultingStorage {
9407 fn new() -> Rc<Self> {
9408 Rc::new(Self {
9409 inner: MemoryStorage::new(),
9410 fail_insert_output: Cell::new(false),
9411 fail_find_output: Cell::new(false),
9412 })
9413 }
9414 }
9415
9416 #[async_trait(?Send)]
9417 impl Storage for FaultingStorage {
9418 async fn put_deferred_graph(
9419 &self,
9420 record: &crate::gasp::DeferredGraph,
9421 ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
9422 self.inner.put_deferred_graph(record).await
9423 }
9424 async fn find_deferred_graphs(
9425 &self,
9426 host: &str,
9427 topic: &str,
9428 ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
9429 self.inner.find_deferred_graphs(host, topic).await
9430 }
9431 async fn get_deferred_graph(
9432 &self,
9433 host: &str,
9434 topic: &str,
9435 outpoint: &str,
9436 ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
9437 self.inner.get_deferred_graph(host, topic, outpoint).await
9438 }
9439 async fn delete_deferred_graph(
9440 &self,
9441 host: &str,
9442 topic: &str,
9443 outpoint: &str,
9444 ) -> Result<(), StorageError> {
9445 self.inner
9446 .delete_deferred_graph(host, topic, outpoint)
9447 .await
9448 }
9449 async fn record_peer_sync_outcome(
9450 &self,
9451 host: &str,
9452 topic: &str,
9453 success: bool,
9454 ) -> Result<(), StorageError> {
9455 self.inner
9456 .record_peer_sync_outcome(host, topic, success)
9457 .await
9458 }
9459 async fn get_peer_sync_health(
9460 &self,
9461 host: &str,
9462 topic: &str,
9463 ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
9464 self.inner.get_peer_sync_health(host, topic).await
9465 }
9466 async fn record_peer_sync_yield(
9467 &self,
9468 host: &str,
9469 topic: &str,
9470 yielded: bool,
9471 ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
9472 self.inner
9473 .record_peer_sync_yield(host, topic, yielded)
9474 .await
9475 }
9476 async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
9477 if self.fail_insert_output.get() {
9478 return Err(StorageError::Database(
9479 "D1_ERROR: storage overloaded".into(),
9480 ));
9481 }
9482 self.inner.insert_output(output).await
9483 }
9484 async fn delete_output(
9485 &self,
9486 txid: &str,
9487 output_index: u32,
9488 topic: &str,
9489 ) -> Result<(), StorageError> {
9490 self.inner.delete_output(txid, output_index, topic).await
9491 }
9492 async fn mark_utxo_as_spent(
9493 &self,
9494 txid: &str,
9495 output_index: u32,
9496 topic: &str,
9497 ) -> Result<(), StorageError> {
9498 self.inner
9499 .mark_utxo_as_spent(txid, output_index, topic)
9500 .await
9501 }
9502 async fn update_consumed_by(
9503 &self,
9504 txid: &str,
9505 output_index: u32,
9506 topic: &str,
9507 consumed_by: &[Outpoint],
9508 ) -> Result<(), StorageError> {
9509 self.inner
9510 .update_consumed_by(txid, output_index, topic, consumed_by)
9511 .await
9512 }
9513 async fn update_transaction_beef(
9514 &self,
9515 txid: &str,
9516 beef: &[u8],
9517 ) -> Result<(), StorageError> {
9518 self.inner.update_transaction_beef(txid, beef).await
9519 }
9520 async fn insert_applied_transaction(
9521 &self,
9522 tx: &AppliedTransaction,
9523 ) -> Result<(), StorageError> {
9524 self.inner.insert_applied_transaction(tx).await
9525 }
9526 async fn does_applied_transaction_exist(
9527 &self,
9528 tx: &AppliedTransaction,
9529 ) -> Result<bool, StorageError> {
9530 self.inner.does_applied_transaction_exist(tx).await
9531 }
9532 async fn delete_applied_transaction(
9533 &self,
9534 tx: &AppliedTransaction,
9535 ) -> Result<(), StorageError> {
9536 self.inner.delete_applied_transaction(tx).await
9537 }
9538 async fn find_output(
9539 &self,
9540 txid: &str,
9541 output_index: u32,
9542 topic: Option<&str>,
9543 spent: Option<bool>,
9544 include_beef: bool,
9545 ) -> Result<Option<Output>, StorageError> {
9546 if self.fail_find_output.get() {
9547 return Err(StorageError::Database("D1_ERROR: read timed out".into()));
9548 }
9549 self.inner
9550 .find_output(txid, output_index, topic, spent, include_beef)
9551 .await
9552 }
9553 async fn find_outputs_for_transaction(
9554 &self,
9555 txid: &str,
9556 include_beef: bool,
9557 ) -> Result<Vec<Output>, StorageError> {
9558 self.inner
9559 .find_outputs_for_transaction(txid, include_beef)
9560 .await
9561 }
9562 async fn find_utxos_for_topic(
9563 &self,
9564 topic: &str,
9565 since: Option<f64>,
9566 limit: Option<u64>,
9567 include_beef: bool,
9568 ) -> Result<Vec<Output>, StorageError> {
9569 self.inner
9570 .find_utxos_for_topic(topic, since, limit, include_beef)
9571 .await
9572 }
9573 async fn update_last_interaction(
9574 &self,
9575 host: &str,
9576 topic: &str,
9577 since: u64,
9578 ) -> Result<(), StorageError> {
9579 self.inner.update_last_interaction(host, topic, since).await
9580 }
9581 async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
9582 self.inner.get_last_interaction(host, topic).await
9583 }
9584 }
9585
9586 fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
9587 let storage = FaultingStorage::new();
9588 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9589 managers.insert(
9590 "tm_test".to_string(),
9591 Box::new(MockTopicManager::admitting(admit_indices)),
9592 );
9593 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
9594 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
9595 let engine = Engine::new(
9596 managers,
9597 lookup_services,
9598 Box::new(Rc::clone(&storage)),
9599 None,
9600 EngineConfig::default(),
9601 );
9602 (engine, storage)
9603 }
9604
9605 async fn applied(storage: &FaultingStorage) -> bool {
9606 storage
9607 .does_applied_transaction_exist(&AppliedTransaction {
9608 txid: TEST_TXID.to_string(),
9609 topic: "tm_test".to_string(),
9610 })
9611 .await
9612 .unwrap()
9613 }
9614
9615 /// The old behaviour, pinned: a clean submit is durable, records the
9616 /// topic as applied, and the output is in storage.
9617 #[tokio::test]
9618 async fn clean_submit_is_durable_and_records_applied() {
9619 let (engine, storage) = make_faulting_engine(vec![0]);
9620 let (steak, report) = engine
9621 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9622 .await
9623 .unwrap();
9624 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
9625 assert!(report.is_durable(), "{}", report.summary());
9626 assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
9627 assert!(applied(&storage).await);
9628 assert!(storage
9629 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
9630 .await
9631 .unwrap()
9632 .is_some());
9633 }
9634
9635 /// THE PHANTOM, red→green: the insert fails, the steak still says
9636 /// "admitted" (the decision stands), but the report says NOT DURABLE and
9637 /// names the site — and the topic is NOT recorded as applied, so nothing
9638 /// downstream can treat the loss as final.
9639 #[tokio::test]
9640 async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
9641 let (engine, storage) = make_faulting_engine(vec![0]);
9642 storage.fail_insert_output.set(true);
9643 let (steak, report) = engine
9644 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9645 .await
9646 .unwrap();
9647 assert_eq!(
9648 steak["tm_test"].outputs_to_admit,
9649 vec![0],
9650 "the admission DECISION is unchanged by a write fault"
9651 );
9652 assert!(!report.is_durable());
9653 assert_eq!(report.faults.len(), 1, "{}", report.summary());
9654 assert_eq!(report.faults[0].topic, "tm_test");
9655 assert_eq!(report.faults[0].site, "insert_output");
9656 assert!(report.faults[0].error.contains("D1_ERROR"));
9657 assert!(report.applied_topics.is_empty());
9658 assert!(
9659 !applied(&storage).await,
9660 "a faulted topic must not be recorded as applied — the replay would be a dupe"
9661 );
9662 assert!(storage
9663 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
9664 .await
9665 .unwrap()
9666 .is_none());
9667 }
9668
9669 /// The replay: same bytes, storage healthy again → re-validated (not a
9670 /// dupe), re-written, recorded. A THIRD submit is then the dupe it
9671 /// should be: nothing admitted, nothing to write, durable.
9672 #[tokio::test]
9673 async fn replay_after_a_fault_reapplies_and_records_applied() {
9674 let (engine, storage) = make_faulting_engine(vec![0]);
9675 storage.fail_insert_output.set(true);
9676 let (_, first) = engine
9677 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9678 .await
9679 .unwrap();
9680 assert!(!first.is_durable());
9681
9682 storage.fail_insert_output.set(false);
9683 let (steak, replay) = engine
9684 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9685 .await
9686 .unwrap();
9687 assert_eq!(
9688 steak["tm_test"].outputs_to_admit,
9689 vec![0],
9690 "re-validated, not deduped"
9691 );
9692 assert!(replay.is_durable(), "{}", replay.summary());
9693 assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
9694 assert!(applied(&storage).await);
9695 assert!(storage
9696 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
9697 .await
9698 .unwrap()
9699 .is_some());
9700
9701 let (steak, third) = engine
9702 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9703 .await
9704 .unwrap();
9705 assert!(
9706 steak["tm_test"].outputs_to_admit.is_empty(),
9707 "now a genuine dupe: nothing admitted"
9708 );
9709 assert!(third.is_durable() && third.applied_topics.is_empty());
9710 }
9711
9712 /// The SPEND half of the phantom: a `find_output` fault during
9713 /// validation made a settle look like it consumed nothing, so the spend
9714 /// pointer was never written and the ack stood. Now the read fault is
9715 /// reported, the topic stays unrecorded, the previously-admitted coin
9716 /// is untouched — and the healthy replay consumes it.
9717 #[tokio::test]
9718 async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
9719 let (engine, storage) = make_faulting_engine(vec![0]);
9720 // The subject's own input: seed the coin it spends as a previously
9721 // admitted tm_test output.
9722 let tx = Transaction::from_beef(&test_beef(), None).unwrap();
9723 let source_txid = tx.inputs[0].get_source_txid().unwrap();
9724 let source_vout = tx.inputs[0].source_output_index;
9725 storage
9726 .insert_output(&Output {
9727 txid: source_txid.clone(),
9728 output_index: source_vout,
9729 output_script: vec![0x51],
9730 satoshis: 1,
9731 topic: "tm_test".to_string(),
9732 spent: false,
9733 outputs_consumed: vec![],
9734 consumed_by: vec![],
9735 beef: None,
9736 block_height: None,
9737 score: Some(1.0),
9738 })
9739 .await
9740 .unwrap();
9741
9742 storage.fail_find_output.set(true);
9743 let (_, faulted) = engine
9744 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9745 .await
9746 .unwrap();
9747 assert!(!faulted.is_durable());
9748 assert!(
9749 faulted.faults.iter().any(|f| f.site == "find_output"),
9750 "{}",
9751 faulted.summary()
9752 );
9753 assert!(
9754 !applied(&storage).await,
9755 "unrecorded: the replay must re-read"
9756 );
9757 storage.fail_find_output.set(false);
9758 assert!(
9759 storage
9760 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
9761 .await
9762 .unwrap()
9763 .is_some(),
9764 "the coin the faulted pass could not see is still there, untouched"
9765 );
9766
9767 let (_, replay) = engine
9768 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
9769 .await
9770 .unwrap();
9771 assert!(replay.is_durable(), "{}", replay.summary());
9772 assert!(applied(&storage).await);
9773 // The mock manager retains no coins, so the consumed coin is stale
9774 // and deleted — the observable proof the spend was PROCESSED.
9775 assert!(
9776 storage
9777 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
9778 .await
9779 .unwrap()
9780 .is_none(),
9781 "the healthy replay consumed the coin the faulted pass missed"
9782 );
9783 }
9784
9785 /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): a lookup service that missed
9786 /// its admit notification is told AGAIN from the engine's own stored
9787 /// outputs — one call per admitted output per service, the WholeTx body
9788 /// naming the subject — while `/submit` of the same bytes is deduped and
9789 /// tells nobody (the premise, RED-verified in the same test); a topic the
9790 /// engine never admitted this txid on yields 0 (the caller re-submits).
9791 #[tokio::test]
9792 async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
9793 use std::cell::RefCell;
9794 use std::rc::Rc;
9795
9796 struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
9797 #[async_trait(?Send)]
9798 impl LookupServiceTrait for CountingLs {
9799 fn admission_mode(&self) -> AdmissionMode {
9800 self.1
9801 }
9802 fn spend_notification_mode(&self) -> SpendNotificationMode {
9803 SpendNotificationMode::None
9804 }
9805 async fn output_admitted_by_topic(
9806 &self,
9807 payload: &OutputAdmittedByTopic,
9808 ) -> Result<(), LookupServiceError> {
9809 let rec = match payload {
9810 OutputAdmittedByTopic::LockingScript {
9811 txid,
9812 output_index,
9813 topic,
9814 ..
9815 } => (txid.clone(), *output_index, topic.clone()),
9816 OutputAdmittedByTopic::WholeTx {
9817 atomic_beef,
9818 output_index,
9819 topic,
9820 ..
9821 } => {
9822 let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
9823 (
9824 beef.atomic_txid
9825 .clone()
9826 .expect("the WholeTx body NAMES its subject"),
9827 *output_index,
9828 topic.clone(),
9829 )
9830 }
9831 };
9832 self.0.borrow_mut().push(rec);
9833 Ok(())
9834 }
9835 async fn output_evicted(
9836 &self,
9837 _txid: &str,
9838 _output_index: u32,
9839 ) -> Result<(), LookupServiceError> {
9840 Ok(())
9841 }
9842 async fn lookup(
9843 &self,
9844 _question: &LookupQuestion,
9845 ) -> Result<LookupResult, LookupServiceError> {
9846 Ok(LookupResult::OutputList(Vec::new()))
9847 }
9848 async fn get_documentation(&self) -> String {
9849 "counting".to_string()
9850 }
9851 async fn get_metadata(&self) -> ServiceMetadata {
9852 ServiceMetadata {
9853 name: "counting-ls".to_string(),
9854 ..Default::default()
9855 }
9856 }
9857 }
9858
9859 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9860 managers.insert(
9861 "tm_test".to_string(),
9862 Box::new(MockTopicManager::admitting(vec![0])),
9863 );
9864 let seen = Rc::new(RefCell::new(Vec::new()));
9865 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
9866 lookup_services.insert(
9867 "ls_whole".to_string(),
9868 Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
9869 );
9870 lookup_services.insert(
9871 "ls_script".to_string(),
9872 Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
9873 );
9874 let engine = Engine::new(
9875 managers,
9876 lookup_services,
9877 Box::new(MemoryStorage::new()),
9878 None,
9879 EngineConfig::default(),
9880 );
9881
9882 let tagged = test_tagged_beef(vec!["tm_test"]);
9883 engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
9884 assert_eq!(seen.borrow().len(), 2, "one admit per service");
9885 let (txid, vout, _) = seen.borrow()[0].clone();
9886 assert_eq!(txid, TEST_TXID);
9887
9888 // the premise, RED-verified: the same bytes again are DEDUPED — /submit can never re-notify
9889 let (_, report) = engine
9890 .submit_with_report(&tagged, SubmitMode::CurrentTx)
9891 .await
9892 .unwrap();
9893 assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
9894 assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
9895
9896 // the verb: told again from the stored outputs, the WholeTx body naming the subject
9897 let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
9898 assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
9899 assert_eq!(r.vouts, vec![vout]);
9900 assert_eq!(seen.borrow().len(), 4);
9901 assert!(seen.borrow()[2..]
9902 .iter()
9903 .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
9904
9905 // a topic the engine never admitted this txid on: nothing to re-notify (re-submit instead)
9906 let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
9907 assert_eq!((r0.outputs, r0.notified), (0, 0));
9908 assert_eq!(seen.borrow().len(), 4);
9909
9910 // idempotent: a second re-notify tells everyone again, faults nothing
9911 let r2 = engine
9912 .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
9913 .await
9914 .unwrap();
9915 assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
9916 assert_eq!(seen.borrow().len(), 6);
9917 }
9918
9919 /// bsv-low PLAN-PRE-LOOP4 §H4: a PHANTOM applied row (the topic admitted
9920 /// NOTHING) dedups every re-submit of those bytes; forgetting it lets the
9921 /// same bytes be judged again — while a REAL admission's row is never
9922 /// forgotten and a missing row is nothing to forget.
9923 #[tokio::test]
9924 async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
9925 // phantom: a TM that admits nothing still records the topic as applied
9926 let engine = make_engine(vec![]);
9927 let tagged = test_tagged_beef(vec!["tm_test"]);
9928 let (steak, r1) = engine
9929 .submit_with_report(&tagged, SubmitMode::CurrentTx)
9930 .await
9931 .unwrap();
9932 assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
9933 assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
9934 let (_, r2) = engine
9935 .submit_with_report(&tagged, SubmitMode::CurrentTx)
9936 .await
9937 .unwrap();
9938 assert_eq!(
9939 r2.deduped_topics,
9940 vec!["tm_test".to_string()],
9941 "the phantom row dedups the re-submit"
9942 );
9943 assert!(engine
9944 .forget_phantom_applied(TEST_TXID, "tm_test")
9945 .await
9946 .unwrap());
9947 let (_, r3) = engine
9948 .submit_with_report(&tagged, SubmitMode::CurrentTx)
9949 .await
9950 .unwrap();
9951 assert!(
9952 r3.deduped_topics.is_empty(),
9953 "judged again after the forget"
9954 );
9955 assert!(
9956 !engine
9957 .forget_phantom_applied(TEST_TXID, "tm_other")
9958 .await
9959 .unwrap(),
9960 "no row: nothing to forget"
9961 );
9962
9963 // real: a TM that admits output 0 — its row is a real admission, never forgotten
9964 let real = make_engine(vec![0]);
9965 real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
9966 assert!(!real
9967 .forget_phantom_applied(TEST_TXID, "tm_test")
9968 .await
9969 .unwrap());
9970 let (_, r4) = real
9971 .submit_with_report(&tagged, SubmitMode::CurrentTx)
9972 .await
9973 .unwrap();
9974 assert_eq!(
9975 r4.deduped_topics,
9976 vec!["tm_test".to_string()],
9977 "a real admission still dedups"
9978 );
9979 }
9980
9981 // ── NL-6e: the SPV walk is linear in a BUMP's leaves (NL-8 W1) ─────
9982
9983 /// Named and generous (a debug build on a loaded machine): the walk at
9984 /// bsv-rs 0.4.0 took 30 to 36 s at 4,096 leaves in release and gave no
9985 /// answer in 120 s at 8,192 (NL-8 W1).
9986 const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
9987
9988 /// The wide BUMP's height (NL-8's `wide k` row).
9989 const WIDE_HEIGHT: u32 = 800_001;
9990
9991 /// `2^k` txids, display hex: the double SHA-256 of `i` (NL-8's probe).
9992 fn wide_leaves(k: u32) -> Vec<String> {
9993 (0..1u32 << k)
9994 .map(|i| {
9995 let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
9996 id.reverse();
9997 hex::encode(id)
9998 })
9999 .collect()
10000 }
10001
10002 /// One BUMP at [`WIDE_HEIGHT`] whose level 0 carries `txids` as hashed
10003 /// leaves and whose `k - 1` upper levels are empty: every node above
10004 /// level 0 is computed from it. Its root, by one walk.
10005 fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
10006 let k = txids.len().trailing_zeros() as usize;
10007 let mut levels = vec![Vec::new(); k];
10008 levels[0] = txids
10009 .iter()
10010 .enumerate()
10011 .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
10012 .collect();
10013 let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
10014 let root = bump.compute_root(None).unwrap();
10015 (bump, root)
10016 }
10017
10018 /// NL-8 W1's bytes: a V1 BEEF of the one wide BUMP and no transaction
10019 /// (`wide_14` at `2^k` leaves, byte for byte the runner's probe).
10020 fn w1_bytes(k: u32) -> (Vec<u8>, String) {
10021 let (bump, root) = wide_bump(&wide_leaves(k));
10022 let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
10023 beef.extend_from_slice(&bump.to_binary());
10024 beef.push(0x00);
10025 (beef, root)
10026 }
10027
10028 /// The entry, [`verify_spv_like_the_reference`], on its own thread under
10029 /// a tracker that carries `root` at [`WIDE_HEIGHT`]: its answer and its
10030 /// time, or a panic naming the bound.
10031 fn walk_within_bound(
10032 bytes: Vec<u8>,
10033 root: String,
10034 verify_scripts: bool,
10035 subject: String,
10036 what: &str,
10037 ) -> (Result<(), String>, std::time::Duration) {
10038 let (tx, rx) = std::sync::mpsc::channel();
10039 std::thread::spawn(move || {
10040 let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
10041 tracker.add_root(WIDE_HEIGHT, root);
10042 let runtime = tokio::runtime::Builder::new_current_thread()
10043 .build()
10044 .unwrap();
10045 let started = std::time::Instant::now();
10046 let verdict = runtime.block_on(verify_spv_like_the_reference(
10047 Some(&tracker),
10048 verify_scripts,
10049 &bytes,
10050 &subject,
10051 DoorBudget::DEFAULT,
10052 ));
10053 let verdict = match verdict {
10054 Ok(None) => Ok(()),
10055 Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
10056 Err(e) => Err(e.to_string()),
10057 };
10058 let _ = tx.send((verdict, started.elapsed()));
10059 });
10060 rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
10061 panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
10062 })
10063 }
10064
10065 /// The escape hatch's structural walk (`verify_scripts` false) on NL-8
10066 /// W1's bytes, 4,096 and 8,192 leaves: accepted, the one root asked of
10067 /// the tracker, well inside the bound.
10068 #[test]
10069 fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
10070 for k in [12u32, 13] {
10071 let (bytes, root) = w1_bytes(k);
10072 let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
10073 let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
10074 println!(
10075 "NL-6e W1 {what}: {verdict:?} in {:.3} s",
10076 took.as_secs_f64()
10077 );
10078 assert_eq!(verdict, Ok(()), "{what}");
10079 }
10080 }
10081
10082 /// The default walk (scripts run, `verify_scripts` true) over `2^k`
10083 /// proven parents in the one wide BUMP, each spent by a binary tree of
10084 /// two-input transactions whose root is the subject: every parent's
10085 /// root is the BUMP's, asked of the tracker, the inputs `2^(k+1) - 2`.
10086 fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
10087 use bsv_rs::script::{LockingScript, UnlockingScript};
10088 use bsv_rs::transaction::{TransactionInput, TransactionOutput};
10089 let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
10090 let spending = |txid: &str| {
10091 let mut input = TransactionInput::new(txid.to_string(), 0);
10092 input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
10093 input
10094 };
10095 let parents: Vec<Transaction> = (0..1u32 << k)
10096 .map(|i| {
10097 let mut tx = Transaction::new();
10098 // A coinbase's null outpoint, a distinct index each.
10099 let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
10100 input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
10101 tx.inputs.push(input);
10102 tx.outputs.push(TransactionOutput::new(1, op_true()));
10103 tx
10104 })
10105 .collect();
10106 let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
10107 let (bump, root) = wide_bump(&ids);
10108 let mut beef = Beef::new();
10109 let at = beef.merge_bump(bump);
10110 for parent in &parents {
10111 beef.merge_raw_tx(parent.to_binary(), Some(at));
10112 }
10113 let mut layer = ids;
10114 while layer.len() > 1 {
10115 layer = layer
10116 .chunks(2)
10117 .map(|pair| {
10118 let mut tx = Transaction::new();
10119 tx.inputs.push(spending(&pair[0]));
10120 tx.inputs.push(spending(&pair[1]));
10121 tx.outputs.push(TransactionOutput::new(0, op_true()));
10122 beef.merge_raw_tx(tx.to_binary(), None);
10123 tx.id()
10124 })
10125 .collect();
10126 }
10127 (beef.to_binary(), root, layer.remove(0))
10128 }
10129
10130 #[test]
10131 fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
10132 for k in [12u32, 13] {
10133 let (bytes, root, subject) = proven_fan_in(k);
10134 let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
10135 let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
10136 println!(
10137 "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
10138 took.as_secs_f64()
10139 );
10140 assert_eq!(verdict, Ok(()), "{what}");
10141 }
10142 }
10143}