bsv_overlay_engine/engine.rs
1//! Overlay Services Engine — the core orchestrator.
2//!
3//! Receives transactions (submit), answers queries (lookup), manages advertisements,
4//! and coordinates GASP sync. All storage, topic management, and lookup service
5//! operations go through trait interfaces.
6//!
7//! Ported from `~/bsv/overlay-services/src/Engine.ts` (1,337 lines).
8
9use crate::beef_limits;
10
11use bsv_rs::transaction::{Beef, Transaction};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use tracing::{error, info, warn};
15
16use crate::advertiser::{Advertiser, AdvertiserError};
17use crate::broadcaster::{ArcBroadcaster, Broadcaster};
18use crate::lookup_service::{LookupService, LookupServiceError};
19use crate::storage::{Storage, StorageError};
20use crate::topic_manager::{TopicManager, TopicManagerError};
21use crate::types::*;
22
23/// Controls how much spend history to include in lookup responses.
24///
25/// Maps to the TS `historySelector` parameter which can be a number
26/// (depth limit) or an async function (per-output decider).
27#[derive(Debug, Clone)]
28pub enum HistorySelector {
29 /// Include up to N levels of ancestor spend history.
30 Depth(u32),
31}
32
33/// Configuration for constructing the Engine.
34pub struct EngineConfig {
35 /// URL where this engine is hosted. Required for advertisement sync.
36 pub hosting_url: Option<String>,
37 /// Known SHIP tracker URLs for bootstrapping.
38 pub ship_trackers: Vec<String>,
39 /// Known SLAP tracker URLs for bootstrapping.
40 pub slap_trackers: Vec<String>,
41 /// Configuration for GASP topic synchronization.
42 pub sync_configuration: SyncConfiguration,
43 /// Whether to suppress default SHIP/SLAP sync advertisements.
44 pub suppress_default_sync_advertisements: bool,
45}
46
47impl Default for EngineConfig {
48 fn default() -> Self {
49 Self {
50 hosting_url: None,
51 ship_trackers: Vec::new(),
52 slap_trackers: Vec::new(),
53 sync_configuration: HashMap::new(),
54 suppress_default_sync_advertisements: true,
55 }
56 }
57}
58
59/// The Overlay Services Engine.
60///
61/// Orchestrates topic managers, lookup services, storage, and advertisements.
62/// Does NOT include an HTTP server — that's the job of overlay-cloudflare.
63pub struct Engine {
64 managers: HashMap<String, Box<dyn TopicManager>>,
65 lookup_services: HashMap<String, Box<dyn LookupService>>,
66 storage: Box<dyn Storage>,
67 advertiser: Option<Box<dyn Advertiser>>,
68 broadcaster: Option<Box<dyn Broadcaster>>,
69 arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
70 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
71 gasp_remote_factory: Option<Box<dyn crate::gasp::GASPRemoteFactory>>,
72 ship_lookup_client: Option<Box<dyn crate::ship_lookup::ShipLookupClient>>,
73 ship_bootstrap_peers: HashMap<String, Vec<String>>,
74 /// OPT-IN / OFF BY DEFAULT chain-backed ancestor fetcher for GASP ingest.
75 /// When `None` (default / production), GASP ingest is byte-identical to
76 /// today. A platform crate may set `Some` (one-time-migration only) to let
77 /// ingest self-heal missing ancestry by fetching it from chain.
78 ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
79 /// bsv-low#302: per-peer GASP sync wall-clock budget —
80 /// `(sleep factory, budget ms)`. `None` (default) = unbounded per-peer
81 /// sync, byte-identical to the pre-#302 behavior. When set, each peer's
82 /// `GASPSync::sync` is raced against `sleep(budget_ms)`; a peer that
83 /// exceeds the budget is DROPPED (loud log, failure recorded, cursor NOT
84 /// advanced) and the loop continues with the next peer.
85 peer_sync_budget: Option<(SleepFactory, u64)>,
86 /// The per-GRAPH GASP budget (bsv-low #555): the sleep, the calls, the
87 /// ms. `None`: no graph is deferred.
88 graph_budget: Option<(SleepFactory, u32, u64)>,
89 /// The per-graph budget's bytes and nodes limbs (bsv-low #586,
90 /// [`Engine::set_graph_budget_limbs`]): in force once a graph budget is
91 /// set.
92 graph_budget_limbs: (u64, u32),
93 /// The bound of one transaction's finalize submit (bsv-low #559), see
94 /// [`Engine::set_finalize_submit_budget`].
95 finalize_submit_budget: Option<(SleepFactory, u64)>,
96 /// Open around each GASP finalize submit, so no deadline that races the
97 /// sync drops a transaction between its writes (bsv-low #552, the lens
98 /// fold's HIGH-1). See [`Engine::finalize_submit_gate`].
99 finalize_gate: crate::gasp::SubmitGate,
100 /// (txid, topic) pairs whose Phase-3 writes FAULTED in this engine and
101 /// have not landed since (bsv-low #559): a successor that finds no coin
102 /// while its predecessor is in here is not recorded as applied. One
103 /// invocation's memory; across invocations the same question is put to
104 /// the store ([`Engine::unlanded_predecessor`]).
105 not_landed: std::cell::RefCell<HashSet<(String, String)>>,
106 /// Asked before the door lands a carried predecessor (bsv-low #575, the
107 /// E1D delta-2 fold, L2). See [`Engine::set_landing_guard`].
108 landing_guard: Option<LandingGuard>,
109 /// Reference-parity spend verification on submit (2026-09-08). `true`
110 /// (DEFAULT): every submit outside `HistoricalTxNoSpv` runs the
111 /// reference's `Transaction.verify` walk: merkle paths against the chain
112 /// tracker AND every input's unlocking script EXECUTED, recursively over
113 /// the unproven ancestry in the BEEF; with no chain tracker the walk is
114 /// the ts-sdk's `'scripts only'` (roots accepted unchecked, scripts still
115 /// run). `false` is an ESCAPE HATCH, not a mode: it restores the
116 /// pre-2026-09-08 structural check (`Beef::verify_valid` + every root
117 /// against the tracker; NOTHING without a tracker), which admitted an
118 /// invalid spend on structure alone. See
119 /// [`Engine::set_script_verification`].
120 verify_scripts: bool,
121 /// The two limbs of the submit's own script walk (bsv-low #592): the
122 /// door's [`DoorBudget`], [`DoorBudget::DEFAULT`] unless set. See
123 /// [`Engine::set_walk_budget`].
124 walk_budget: DoorBudget,
125 config: EngineConfig,
126}
127
128/// Boxed platform sleep future (bsv-low#302). Not `Send` — the engine runs
129/// single-threaded on wasm (Workers) and under `#[tokio::test]` locally.
130pub type SleepFuture = std::pin::Pin<Box<dyn std::future::Future<Output = ()>>>;
131
132/// Platform sleep factory (bsv-low#302): milliseconds → a future that
133/// resolves after that delay. The Cloudflare crate passes its `sleep_ms`;
134/// tests pass `ready(())` (instant deadline) or `pending()` (no deadline)
135/// to drive the race deterministically.
136pub type SleepFactory = std::rc::Rc<dyn Fn(u64) -> SleepFuture>;
137
138/// The answer of a [`LandingGuard`]: `Ok` lets the body land, `Err` says why
139/// it may not (the topic waits).
140pub type LandingGuardFuture =
141 std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
142
143/// The caller's admission predicate over a carried predecessor the door is
144/// about to land: `(predecessor txid, topic)`. See
145/// [`Engine::set_landing_guard`].
146pub type LandingGuard = std::rc::Rc<dyn Fn(&str, &str) -> LandingGuardFuture>;
147
148/// The bound of ONE GASP finalize submit's storage calls and hooks (bsv-low
149/// #559, the lens fold of 2026-10-07, F2). The submit itself is never
150/// dropped: it is the write section no deadline drops, and c9921ee, which
151/// raced the whole submit against its budget inside the section, dropped it
152/// between two writes all the same. Each storage call, lookup hook and topic
153/// manager call inside it is raced against the section's ONE deadline
154/// instead, and a call that has not answered when the deadline falls due is
155/// dropped ALONE and reported as that call's fault: the submit runs on to
156/// its end as for any storage fault. Once the deadline is due no further
157/// call is started (each answers "no answer" at once), so a transaction
158/// costs at most its budget, and once more for the undo
159/// ([`CallBound::renew`]).
160struct CallBound {
161 sleep: SleepFactory,
162 budget_ms: u64,
163 deadline: std::cell::RefCell<SleepFuture>,
164 due: std::cell::Cell<bool>,
165}
166
167impl CallBound {
168 fn new(sleep: &SleepFactory, budget_ms: u64) -> Self {
169 Self {
170 deadline: std::cell::RefCell::new(sleep(budget_ms)),
171 sleep: sleep.clone(),
172 budget_ms,
173 due: std::cell::Cell::new(false),
174 }
175 }
176
177 /// A fresh allowance of the same length, for the undo of a faulted
178 /// submit: the calls that put the store back as it was found must not be
179 /// refused because the deadline the fault answered to has passed.
180 fn renew(&self) {
181 *self.deadline.borrow_mut() = (self.sleep)(self.budget_ms);
182 self.due.set(false);
183 }
184
185 /// Whether a call under this bound went unanswered (or was not started
186 /// because one had) since the last [`CallBound::renew`].
187 fn is_due(&self) -> bool {
188 self.due.get()
189 }
190
191 /// `call`, or `None` when the deadline fell due first.
192 async fn call<T>(&self, call: impl std::future::Future<Output = T>) -> Option<T> {
193 if self.due.get() {
194 return None;
195 }
196 let mut call = std::pin::pin!(call);
197 std::future::poll_fn(|cx| {
198 if let std::task::Poll::Ready(v) = call.as_mut().poll(cx) {
199 return std::task::Poll::Ready(Some(v));
200 }
201 if self.deadline.borrow_mut().as_mut().poll(cx).is_ready() {
202 self.due.set(true);
203 return std::task::Poll::Ready(None);
204 }
205 std::task::Poll::Pending
206 })
207 .await
208 }
209}
210
211/// `call` under `bound` (or unbounded with none); `Err` is "no answer".
212async fn bounded<T>(
213 bound: Option<&CallBound>,
214 call: impl std::future::Future<Output = T>,
215) -> Result<T, String> {
216 match bound {
217 Some(bound) => bound
218 .call(call)
219 .await
220 .ok_or_else(|| format!("no answer within {} ms", bound.budget_ms)),
221 None => Ok(call.await),
222 }
223}
224
225/// A storage call under `bound`: no answer in time is that call's fault.
226async fn stored<T>(
227 bound: Option<&CallBound>,
228 call: impl std::future::Future<Output = Result<T, StorageError>>,
229) -> Result<T, StorageError> {
230 match bounded(bound, call).await {
231 Ok(answer) => answer,
232 Err(no_answer) => Err(StorageError::Database(no_answer)),
233 }
234}
235
236/// A lookup hook under `bound`, its error as the report carries it.
237async fn hooked(
238 bound: Option<&CallBound>,
239 call: impl std::future::Future<Output = Result<(), LookupServiceError>>,
240) -> Result<(), String> {
241 bounded(bound, call).await?.map_err(|e| e.to_string())
242}
243
244/// The most store reads [`Engine::unlanded_predecessor`] makes for one
245/// topic of one submit (bsv-low #559, F3): the question is asked of every
246/// transaction that admits nothing and found no coin, so it is kept cheap.
247/// Counted are the reads spent on a body the BEEF does not prove and the
248/// store does not hold as landed (the second delta fold of 2026-10-07, M2);
249/// the 17th of those is never made: out of reads is "not now" (the delta
250/// fold of 2026-10-07, M2). An unproven body still needs room under it for
251/// the reads that find it landed (one for an applied row, two for a held
252/// output): each is tested before it is made.
253const PREDECESSOR_READS: usize = 16;
254
255/// The most store reads [`Engine::unlanded_predecessor`] makes for one
256/// SUBMIT (the third delta fold of 2026-10-07, M1): every read of the
257/// question, counted against [`PREDECESSOR_READS`] or not (those of proven
258/// and landed bodies included), in every topic of the submit together. The
259/// 257th is never made: past the allowance the answer is "not now", never
260/// "landed". Without it the question read as far as the BEEF reached (one
261/// read per 41 bytes: 66,305 reads measured from one 2.7 MB BEEF), on a
262/// public route. Per submit and not per topic because what it protects, the
263/// invocation's D1 allowance, is per invocation; 256 is the door's own unit
264/// ([`DoorBudget`]'s inputs per transaction), no derived figure.
265const PREDECESSOR_READS_PER_SUBMIT: usize = 256;
266
267/// Why a successor must wait ([`Engine::unlanded_predecessor`]), and the
268/// unlanded predecessor whose body the BEEF carries, when the answer names
269/// one: the door lands it first ([`Engine::land_carried`]).
270/// A submit's STEAK and report, and the carried predecessor that blocks each
271/// topic of a carried predecessor's own submit ([`Engine::submit_counted`]).
272type Counted = (Steak, MutationReport, HashMap<String, String>);
273
274/// What the landings of carried predecessors take from their successor's
275/// submit (the E592 lens fold, M1): its breach policy, and whether its own
276/// walk RAN (only then are the bodies it reached trusted).
277#[derive(Debug, Clone, Copy)]
278struct Landing {
279 policy: WalkBreachPolicy,
280 subject_walk_ran: bool,
281}
282
283struct NotNow {
284 why: String,
285 carried: Option<String>,
286}
287
288/// Summary of an [`Engine::complete_missing_proofs`] pass.
289///
290/// All counts are over the single bounded page scanned this tick. A no-fetcher
291/// (production) build returns the all-zero default.
292#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
293pub struct ProofCompletionSummary {
294 /// Stored transactions inspected this tick (the bounded page size).
295 pub scanned: usize,
296 /// Of `scanned`, how many still lacked a merkle proof for their own tx.
297 pub proofless: usize,
298 /// Proofless txs whose BUMP was fetched + stitched into the stored BEEF.
299 pub completed: usize,
300 /// Proofless txs the fetcher could serve but that are not yet mined
301 /// (no BUMP yet) — retried on a later tick.
302 pub still_unconfirmed: usize,
303 /// Proofless txs the fetcher errored on (e.g. budget exhausted, 429) —
304 /// retried on a later tick.
305 pub fetch_failed: usize,
306 /// Proofless txs whose proof came back but failed to stitch into storage.
307 pub stitch_failed: usize,
308 /// Scanned rows that ALREADY carried a valid proof (stale `has_proof = 0`
309 /// flag) and were marked proven this tick so they drop out of the candidate
310 /// window — clearing the window-clog (#130).
311 pub already_proven: usize,
312}
313
314/// Outcome of one [`Engine::sync_advertisements`] run (bsv-low #320 defect
315/// 3a).
316///
317/// The engine used to swallow create/submit failures into `error!` and
318/// return `Ok(())`, so the admin route reported `success` while zero
319/// advertisements were admitted locally; a caller could not tell a
320/// converged no-op (`to_create == 0`) from a silent failure. Every field
321/// here is observational — the sync's behavior is unchanged, its outcome is
322/// no longer hidden.
323#[derive(Debug, Default, Clone, Serialize)]
324#[serde(rename_all = "camelCase")]
325pub struct SyncAdvertisementsReport {
326 /// Advertisements the diff found missing (attempted this run). A
327 /// converged node reports 0 here.
328 pub to_create: usize,
329 /// Stale advertisements the diff said to revoke.
330 pub to_revoke: usize,
331 /// `advertiser.create_advertisements` failure, verbatim.
332 #[serde(skip_serializing_if = "Option::is_none")]
333 pub create_error: Option<String>,
334 /// Local `Engine::submit` failure for the created ads, verbatim — the
335 /// path that silently hid the self-admission failure live (our own
336 /// ls_ship/ls_slap never gained our ads, so every cycle re-created all
337 /// of them).
338 #[serde(skip_serializing_if = "Option::is_none")]
339 pub submit_error: Option<String>,
340 /// Outputs admitted per topic by the local submit (from the STEAK).
341 /// A successful submit that admitted NOTHING (topic-manager refusal)
342 /// shows up here as explicit zeros.
343 pub admitted: std::collections::BTreeMap<String, usize>,
344 /// `advertiser.find_all_advertisements` failure, verbatim (#320 M2).
345 /// When set, creation was REFUSED this run: a blind read must never
346 /// become "current ads = none" and re-create (re-pay) the entire set.
347 #[serde(skip_serializing_if = "Option::is_none")]
348 pub lookup_error: Option<String>,
349 /// Stale ads the diff wanted revoked but the advertiser declined to
350 /// build a revocation for (the CF advertiser's documented v1 no-op —
351 /// #320 L2). Distinguishes "revoke happened" from "revoke skipped".
352 pub revoke_skipped: usize,
353 /// `advertiser.revoke_advertisements` failure, verbatim.
354 #[serde(skip_serializing_if = "Option::is_none")]
355 pub revoke_error: Option<String>,
356 /// Local `Engine::submit` failure for the revocation, verbatim.
357 #[serde(skip_serializing_if = "Option::is_none")]
358 pub revoke_submit_error: Option<String>,
359}
360
361impl SyncAdvertisementsReport {
362 /// True when nothing errored. NOTE: a submit that admitted zero outputs
363 /// is not an error at this layer — that refusal is [`Self::effective`]'s
364 /// job.
365 #[must_use]
366 pub fn ok(&self) -> bool {
367 self.create_error.is_none()
368 && self.submit_error.is_none()
369 && self.lookup_error.is_none()
370 && self.revoke_error.is_none()
371 && self.revoke_submit_error.is_none()
372 }
373
374 /// Total outputs admitted by the local submit across topics.
375 #[must_use]
376 pub fn admitted_total(&self) -> usize {
377 self.admitted.values().sum()
378 }
379
380 /// True when the run errored nowhere AND the local submit admitted
381 /// EVERY created ad (#320 M1 + delta D1): each advertisement is exactly
382 /// one output and change is never a SHIP/SLAP token, so full admission
383 /// means `admitted_total() >= to_create` (defensive `>=`). A weaker
384 /// `> 0` bar let a PARTIAL admit pass — the live set is 9 SHIP + 8 SLAP
385 /// in ONE tx with independent per-topic admission, so tm_ship admitting
386 /// its 9 while tm_slap refused all 8 read as success while the diff
387 /// re-created (re-paid for) the refused 8 every cycle. Subsumes the
388 /// converged no-op (`0 >= 0`).
389 #[must_use]
390 pub fn effective(&self) -> bool {
391 self.ok() && self.admitted_total() >= self.to_create
392 }
393}
394
395/// Internal result from Phase 1 + 2 validation.
396///
397/// Carries per-topic admittance decisions, previous coins, and the parsed
398/// transaction so that Phase 3 (mutations) can proceed without re-parsing.
399struct TopicValidation {
400 topic: String,
401 is_dupe: bool,
402 /// Input indices that spend previously-admitted outputs from this topic.
403 previous_coins: Vec<u32>,
404 /// The previous outputs found in storage (parallel to previous_coins).
405 previous_outputs: Vec<Output>,
406 admittance: AdmittanceInstructions,
407 failed: bool,
408 /// The FIRST storage read fault hit while scanning this topic's
409 /// previous outputs (S2, bsv-low 2026-08-29). A faulted read makes a
410 /// spend look like it consumes nothing — the settle's spend pointer is
411 /// then never written — so it is carried into the [`MutationReport`]
412 /// and blocks the `applied_transactions` record for this topic: a
413 /// replay re-reads instead of being deduplicated away. With the read
414 /// that faulted: `find_output` (a previous coin) or
415 /// `does_applied_transaction_exist` (the dedup read, the delta fold of
416 /// 2026-10-07, H3).
417 read_fault: Option<(&'static str, String)>,
418}
419
420/// One Phase-3 write — or the validation read it depends on — that FAILED
421/// during a submit (S2 queue-durable admission, bsv-low 2026-08-29).
422///
423/// Before this existed every such failure was `error!`-logged and swallowed
424/// while `submit` returned `Ok(steak)`: under a D1-overload storm the
425/// overlay ACKED admissions whose writes never landed (the 2026-08-26
426/// phantom class — `c4d2ed06…`/`62c2a9b3…` absent from the engine store,
427/// beta D1 queried directly). A fault is now a VALUE the caller can act on
428/// (re-queue for an idempotent replay) instead of a log line nobody reads.
429#[derive(Debug, Clone, PartialEq, Eq)]
430pub struct MutationFault {
431 /// The topic whose mutation faulted.
432 pub topic: String,
433 /// The write (or read) that failed: `insert_output`,
434 /// `mark_utxo_as_spent`, `update_consumed_by`, `delete_utxo_deep`,
435 /// `insert_applied_transaction`, `find_output` (validation read),
436 /// `does_applied_transaction_exist` (the dedup read),
437 /// `lookup_service.output_spent`, `lookup_service.output_admitted_by_topic`,
438 /// `undo_insert_output` (the undo of a faulted submit's inserts),
439 /// `record_spent_coin_applied` (the applied row a spender writes for the
440 /// transaction of a coin it deletes), `predecessor_not_landed`.
441 pub site: &'static str,
442 /// The backend's own error text.
443 pub error: String,
444}
445
446/// What Phase 3 actually landed for a submit — the durability report.
447///
448/// `applied_topics` are the topics whose EVERY write (and lookup-service
449/// notification) succeeded and were therefore recorded in
450/// `applied_transactions`. A topic with any fault is deliberately NOT
451/// recorded: the dedup in Phase 1 would otherwise turn a replay into a
452/// no-op and make the loss permanent (the ordering was already
453/// write-then-record; the record is now conditional on the writes).
454#[derive(Debug, Default, Clone, PartialEq, Eq)]
455pub struct MutationReport {
456 pub faults: Vec<MutationFault>,
457 pub applied_topics: Vec<String>,
458 /// Topics this (txid, topic) pair had ALREADY been applied under — the
459 /// Phase-1 dedup skipped them, so their STEAK entry is an empty default
460 /// that means "nothing new", never "judged another tx" (loop-3 client
461 /// belt re-presents read the empty STEAK as index-pending).
462 pub deduped_topics: Vec<String>,
463 /// The predecessors the door LANDED first from this submit's BEEF, as
464 /// `(txid, topic)`, ancestors first ([`Engine::land_carried`]; the E1D
465 /// delta fold, L2). Each is an admission write of its own that no
466 /// caller asked for: a caller that guards its admission writes (the
467 /// worker's eviction ledger, bsv-low #513) guards these too.
468 pub landed_predecessors: Vec<(String, String)>,
469 /// The submit's own script walk could not run within the engine's
470 /// budget (bsv-low #592) and the submit went on as under
471 /// `HistoricalTxNoSpv` ([`WalkBreachPolicy::NetworkAccepted`] only; under
472 /// `NotNow` a breach is [`EngineError::WalkCouldNotRun`]). `None` when it
473 /// ran (or the mode does not walk). A caller counts it; it never makes a
474 /// report undurable.
475 pub walk_could_not_run: Option<WalkCouldNotRun>,
476 /// The walks of the carried predecessors this submit tried to land
477 /// ([`Engine::land_carried`]) that could not run within the budget (the
478 /// E592 lens fold, L3): under `NotNow` each one's landing did not happen
479 /// (the successor waits), under `NetworkAccepted` each went on. Counted
480 /// by the caller beside `walk_could_not_run`.
481 pub landed_walks_could_not_run: Vec<WalkCouldNotRun>,
482}
483
484/// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): what [`Engine::renotify_admitted`] did.
485#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
486pub struct RenotifyReport {
487 /// stored outputs of (txid, topic) the engine had ALREADY admitted
488 pub outputs: u32,
489 /// lookup-service notifications that returned Ok (outputs × services)
490 pub notified: u32,
491 /// notifications that failed — `<service>: <error>`; the others still ran
492 pub faults: Vec<String>,
493 /// the output indexes re-notified (the caller's spend discovery keys on them)
494 pub vouts: Vec<u32>,
495}
496
497impl MutationReport {
498 fn fault(&mut self, topic: &str, site: &'static str, error: String) {
499 self.faults.push(MutationFault {
500 topic: topic.to_string(),
501 site,
502 error,
503 });
504 }
505
506 /// True when every mutation this submit needed landed (nothing to
507 /// replay). A dupe-only submit is durable (there was nothing to write).
508 #[must_use]
509 pub fn is_durable(&self) -> bool {
510 self.faults.is_empty()
511 }
512
513 /// One log line naming every fault (`topic/site: error`), each error
514 /// clipped so a D1 stack dump cannot flood the log stream.
515 #[must_use]
516 pub fn summary(&self) -> String {
517 self.faults
518 .iter()
519 .map(|f| {
520 let err: String = f.error.chars().take(120).collect();
521 format!("{}/{}: {}", f.topic, f.site, err)
522 })
523 .collect::<Vec<_>>()
524 .join("; ")
525 }
526}
527
528/// The DOOR's static work bound (bsv-low W-A gate MED-1, 2026-09-09). Bitcoin
529/// script has no loops: every opcode runs at most once, so an input's work is
530/// bounded BEFORE execution by its opcode census times the interpreter's
531/// element limit (the stack memory limit bounds every element, and every
532/// CAT / NUM2BIN / SPLIT result with it) plus its signature checks times the
533/// transaction's own size or a floor, whichever is more (a BIP-143 preimage
534/// hashes the prevouts and the outputs; the EC verification costs the same
535/// whatever the transaction weighs). The estimate is computed from the bytes;
536/// nothing executes past the budget, and a budget breach is the DOOR's
537/// verdict (inconclusive: the network judges), never the interpreter's.
538///
539/// THE BODY IS NOT COUNTED (bsv-low #585, door 1). Until #585 the budget also
540/// counted the BODY: 64 unproven transactions, 256 inputs per transaction,
541/// 512 KB per transaction, and the parse's own size and counts. A valid BEEF
542/// is never left unjudged for its size or its counts, so those are gone: the
543/// walk reads the body through the streaming reader ([`crate::script_door`]).
544///
545/// TWO LIMBS, both a budget whose breach is "the network judges" and never a
546/// refusal: the WORK the interpreter will do, and the MEMORY the door will
547/// hold beside the caller's body. Each is estimated from the bytes before it
548/// is spent.
549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub struct DoorBudget {
551 /// The interpreter's stack memory limit: bounds every element, and is the
552 /// unit of the work estimate (a hash-class opcode is charged one element).
553 /// Not a bound on the BEEF: a script that trips it is "the network
554 /// judges", as a breach of the work is.
555 pub memory_limit: usize,
556 /// The whole walk's estimated work, in bytes hashed or pushed.
557 pub max_work_bytes: u64,
558 /// The least a signature check is charged (the doors lens E585-D12-M1).
559 /// A check is charged `max(its transaction's bytes, this)`: the digest
560 /// hashes about the transaction, and the EC verification behind it costs
561 /// the same whatever the transaction weighs (about 95 us native). Charged
562 /// the transaction's bytes alone, a 106 byte `OP_CHECKSIG OP_NOT`
563 /// transaction bought a verification for 106 bytes of budget: 90,000 of
564 /// them walked at a fifth of the budget in 9.6 s. With the floor the
565 /// budget bounds the EC verifications of one walk at
566 /// `max_work_bytes / sig_check_floor`.
567 pub sig_check_floor: u64,
568 /// The whole walk's estimated memory beside the body, in bytes (the doors
569 /// lens E585-D12-L3): what the index, the stream's element in hand and
570 /// the walk's layout of a transaction hold, estimated from the frame's
571 /// lengths and counts by [`crate::stream_sizing::estimate`] BEFORE the
572 /// stream is opened; and, per input, what its two scripts hold once
573 /// parsed and run (the delta lens E585-D12-DELTA-M1: bsv-rs's chunk
574 /// records, their clones, a signature's subscript, the empty stack
575 /// entries the interpreter's memory limit does not count), charged from
576 /// the scripts' chunk count and bytes BEFORE either is parsed
577 /// (`script_door::script_parse_charge`, 512 bytes a chunk and 8 a byte).
578 ///
579 /// How the two compose: the frame's estimate is the most the walk holds
580 /// at any moment apart from the input being run (what is kept, and the
581 /// heaviest transaction in hand, counted once with its raw bytes and the
582 /// interpreter's raw copies of the unlocking script); the scripts' charge
583 /// is the EXPANSION of one input's scripts past those bytes, held while
584 /// that input runs and released after it. An input is run only while the
585 /// frame's estimate plus its own charge is within this limb.
586 pub max_memory_bytes: u64,
587}
588
589impl DoorBudget {
590 /// Sized for LOW's real shapes with a wide margin. What the static census
591 /// BOUNDS: hashing (hash-class ops × the element limit) and signature
592 /// checks (× the transaction's size or the floor, whichever is more; a
593 /// CHECKMULTISIG weighted by its static key count when the count is a
594 /// small-int push, else by the most keys the element limit admits).
595 ///
596 /// THE FLOOR, 64 KiB (half the hash charge), chosen against LOW's shapes
597 /// as the pin `door_low_shapes_sit_under_a_quarter_of_the_work_budget`
598 /// measures them (work, of the 64 MiB; memory, the frame's and the
599 /// largest input's scripts, of the 48 MiB limb; work before the floor in
600 /// brackets):
601 ///
602 /// | shape | inputs | work | memory |
603 /// |---|---|---|---|
604 /// | the real covenant settle (5 hash opcodes, 4 checks: a CHECKSIG and the stated `OP_3` CHECKMULTISIG) | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
605 /// | the real covenant refund | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
606 /// | LOW's JOIN: two seats (bsv-low `template_tx_sizes.rs:76`), the real pot lock, each coin 40 unproven P2PKH hops deep (the deepest the fleet's record names) | 82 | 16,132,638, 24.0 % | 67,026, 0.13 % |
607 /// | STRESS, not LOW's shape: nine seats, three hops each | 36 | 7,082,619, 10.6 % (4,740,838) | 36,420, 0.07 % |
608 /// | a coin 30 P2PKH hops deep | 30 | 5,902,188, 8.8 % (3,941,856) | 23,720, 0.05 % |
609 ///
610 /// A P2PKH input is charged 196,739 bytes (a hash opcode at
611 /// the element limit, a check at the floor, its 131 script bytes): 341 of
612 /// them are the budget, 85 a quarter of it. The bound on EC verifications
613 /// per walk is 64 MiB / 64 KiB = 1,024 (1,023 with their script bytes):
614 /// measured natively, 1,000 of them walk in 87 ms in the release profile
615 /// (1.4 to 1.8 s in a debug build), and the lens's 17,000 stop at the
616 /// 1,024th in 108 ms. wasm32 was not measured.
617 ///
618 /// THE MEMORY, 48 MiB: three eighths of a 128 MB isolate. Beside it stand,
619 /// for a 10 MB request (the figure the arithmetic is made at: since NL-6
620 /// the route caps no body, so a heavier one is the isolate's), the
621 /// request's body, the completed BEEF the route hands the door (a second
622 /// copy of up to as much) and the EF batch (the route's 2 MB bound, twice
623 /// while it is serialized): 24 MB, which leaves 56 MB for the module, the
624 /// runtime, the allocator's fragmentation and what a native estimate does
625 /// not see of wasm32. The limb bounds the walk (the door's, and since
626 /// bsv-low #592 the submit's own and the GASP anchor's under
627 /// [`Engine::set_walk_budget`]) and nothing else: the route's own
628 /// hydrated parses before it are bounded by the isolate only (the land
629 /// lens E585-LAND-M1: 1.43 GB natively for a 9.9 MB body of 900,000
630 /// minimal transactions, before the route's 429). What
631 /// it lets through, by the door's charges (`script_door::DOOR_CHARGES`,
632 /// each an upper bound: the pins measure 1.3 to 5.3 times the heap the
633 /// walk reaches): a BUMP of up to 72,944 leaves (690 bytes a level-0
634 /// leaf); a single transaction of up to about 12 MB (4 bytes a byte)
635 /// whose walked inputs' scripts are few chunks (a script is charged 520
636 /// bytes a one-byte opcode and 8 a pushed byte: an input whose two
637 /// scripts are about 96,000 opcodes, or a 1 MB `OP_NOP` lock, is past
638 /// the limb before it is parsed, the network judges; LOW's covenant leg,
639 /// 6,608 script bytes, is charged 1.3 MB); a 10 MB body of one-input,
640 /// one-output P2PKH transactions (191 bytes, charged 570: 30 MB). What it
641 /// stops: 111,848 transactions of no input and no output, whatever
642 /// follows them.
643 ///
644 /// THE OPEN RESIDUAL (the W-A gate's delta-verify, 2026-09-09, measured):
645 /// the interpreter itself meters nothing, so two classes stay charged
646 /// only by their script bytes — bignum arithmetic (`OP_MUL`/`OP_DIV`/
647 /// `OP_MOD` on operands up to the element limit: ~1.3 ms per 30 KB×30 KB
648 /// round, linear in rounds) and `OP_NUM2BIN`, which
649 /// allocates its size operand BEFORE the memory limit is consulted (up to
650 /// bsv-rs's 1 GB element size: an isolate kill, not a refusal). None of
651 /// them can refuse a spend (a breach and a trip are the door's bound and
652 /// the request proceeds); all of them cost the beta overlay CPU until
653 /// bsv-rs meters work dynamically (a `work_limit` charged per op with the
654 /// real operand sizes, and a pre-allocation check in NUM2BIN) — OWED
655 /// before any PROD flip of `SCRIPT_VERIFY_NETWORK_GATED`. Since #585 the
656 /// count of such rounds is bounded by the body alone (the script bytes
657 /// are charged, the transactions and inputs are not counted). A third,
658 /// as before #585 (the doors lens N7): an opcode that copies an element
659 /// (`OP_DUP`, `OP_CAT`, `OP_PICK`) is charged its one script byte and can
660 /// copy up to the element limit. The class the floor CLOSED: a
661 /// CHECKMULTISIG whose key count is a computed value was charged the most
662 /// keys the element limit admits (3,971) at its transaction's bytes and
663 /// run (~80 µs per key tried); at the floor those are 260 MB, past the
664 /// budget, so such an input is never run at the door (the network
665 /// judges). LOW's covenant states its count (`OP_3`).
666 pub const DEFAULT: DoorBudget = DoorBudget {
667 memory_limit: 128 * 1024,
668 max_work_bytes: 64 * 1024 * 1024,
669 sig_check_floor: 64 * 1024,
670 max_memory_bytes: 48 * 1024 * 1024,
671 };
672}
673
674/// Which limb of the [`DoorBudget`] a walk went past.
675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
676pub enum DoorLimb {
677 /// The static work estimate, or the interpreter's own memory limit
678 /// tripping inside one input.
679 Work,
680 /// The estimate of what the door holds beside the body.
681 Memory,
682}
683
684/// What [`Engine::run_validation`] answers: the topics' validations, the
685/// preliminary STEAK, the subject and its txid, and whether the walk could
686/// not run within the budget (bsv-low #592).
687type Validated = (
688 Vec<TopicValidation>,
689 Steak,
690 Transaction,
691 String,
692 Option<WalkCouldNotRun>,
693);
694
695/// Which limb of the [`DoorBudget`] a submit's own walk went past (bsv-low
696/// #592): the [`DoorLimb`] of the door's error, with the interpreter's own
697/// memory limit told apart from the static work estimate.
698#[derive(Debug, Clone, Copy, PartialEq, Eq)]
699pub enum WalkLimb {
700 /// The static work estimate.
701 OverWork,
702 /// The estimate of what the walk holds beside the body.
703 OverMemory,
704 /// The interpreter's own memory limit (the budget's element limit)
705 /// tripping inside one input.
706 InterpreterMemory,
707}
708
709impl WalkLimb {
710 /// The door's word for the same limb ([`DoorLimb`]: the interpreter's
711 /// trip is `Work` there).
712 #[must_use]
713 pub fn door_limb(self) -> DoorLimb {
714 match self {
715 WalkLimb::OverWork | WalkLimb::InterpreterMemory => DoorLimb::Work,
716 WalkLimb::OverMemory => DoorLimb::Memory,
717 }
718 }
719
720 /// The limb's name in a log line and a counter's reason.
721 #[must_use]
722 pub fn as_str(self) -> &'static str {
723 match self {
724 WalkLimb::OverWork => "over_work",
725 WalkLimb::OverMemory => "over_memory",
726 WalkLimb::InterpreterMemory => "interpreter_memory",
727 }
728 }
729}
730
731/// What a submit does when its own script walk could not run within the
732/// engine's [`DoorBudget`] (bsv-low #592; the E592 lens fold, H1, the owner's
733/// ruling 3a read with the lens): a breach is NEVER an admission without the
734/// network's word on record.
735///
736/// The work limb is a static estimate from the bytes, and a LOCK can be made
737/// to pass it cheaply (a bare `OP_CHECKMULTISIG` in a fabricated parent's
738/// lock is charged 3,971 keys). So a breach says nothing about whether the
739/// spend is valid, and on a path where nothing else judges it an admission
740/// would admit a spend nobody checked: the lens's forged spend of a held coin.
741#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
742pub enum WalkBreachPolicy {
743 /// "Not now" ([`EngineError::WalkCouldNotRun`]): nothing judged, nothing
744 /// written, never a final refusal. Every door but one: the ungated
745 /// modes, `/admin/readmit`, the peer crawler, a library consumer's
746 /// submit; the GASP anchor HOLDS its UTXO the same way.
747 #[default]
748 NotNow,
749 /// The network's ACCEPT of these bytes is on record (the queue's replay
750 /// of a gated submission, whose producer broadcast them and marked the
751 /// message): the submit goes on exactly as under `HistoricalTxNoSpv`,
752 /// reported on [`MutationReport::walk_could_not_run`]. The roots the walk
753 /// reached are still checked, and a bad one is still the `SpvError` it is.
754 NetworkAccepted,
755}
756
757/// A submit whose own script walk COULD NOT RUN within the engine's
758/// [`DoorBudget`] (bsv-low #592, the owner's ruling 3a of 2026-10-10). Shaped
759/// like [`EngineError::ScriptWalkOverBudget`]. Under
760/// [`WalkBreachPolicy::NotNow`] it is carried by
761/// [`EngineError::WalkCouldNotRun`] (not now); under
762/// [`WalkBreachPolicy::NetworkAccepted`] the submit went on as under
763/// `HistoricalTxNoSpv` and this says so on its [`MutationReport`]. The
764/// reference (`Engine.submit`, `tx.verify`) has no budget; this is the
765/// platform's addition, on the door's lineage (D8).
766#[derive(Debug, Clone, PartialEq, Eq)]
767pub struct WalkCouldNotRun {
768 /// The submitted transaction.
769 pub subject_txid: String,
770 /// The transaction the walk stopped at.
771 pub at_txid: String,
772 /// Whether the subject's own inputs had all executed before the stop.
773 pub subject_judged: bool,
774 /// The limb it went past.
775 pub limb: WalkLimb,
776 /// The estimate against the limb, in the door's words.
777 pub what: String,
778}
779
780/// What the walk did (the door's cost instrument, since `Date.now()` is
781/// frozen during synchronous work on Workers and cannot time it).
782#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
783pub struct WalkStats {
784 /// Unproven transactions whose inputs were executed.
785 pub unproven_txs: usize,
786 /// Inputs executed (scripts run).
787 pub inputs_executed: usize,
788 /// Unlocking + locking script bytes executed.
789 pub script_bytes: usize,
790 /// Hash-class opcodes seen (SHA1/SHA256/HASH160/HASH256/RIPEMD160).
791 pub hash_ops: usize,
792 /// Signature checks seen (CHECKSIG counts 1, CHECKMULTISIG counts its
793 /// consensus maximum of 20).
794 pub sig_ops: usize,
795 /// The static work estimate charged against the budget.
796 pub work_bytes: u64,
797 /// The memory estimate charged against the budget: the frame's, made
798 /// before the stream was opened, plus the largest charge of one walked
799 /// input's scripts, made before they were parsed.
800 pub memory_bytes: u64,
801 /// Whether the SUBJECT's own inputs were all executed (an ancestor may
802 /// still have faulted afterwards).
803 pub subject_judged: bool,
804}
805
806/// Static opcode census of one input's scripts: (bytes, hash ops, sig ops).
807///
808/// A CHECKMULTISIG is weighted by its KEY COUNT: the small-int push that
809/// precedes it when the script states the count (the Poc5 covenant's
810/// `OP_3`), else the most 33-byte keys the element limit admits (the count is
811/// a computed value the census cannot see; bsv-rs allows up to `i32::MAX`).
812pub(crate) fn script_census(
813 unlocking: &[u8],
814 locking: &[u8],
815 memory_limit: usize,
816) -> Result<(usize, usize, usize), String> {
817 use bsv_rs::script::op::*;
818 let most_keys = (memory_limit / 33).max(1);
819 let mut hash_ops = 0usize;
820 let mut sig_ops = 0usize;
821 for (label, bytes) in [("unlocking", unlocking), ("locking", locking)] {
822 let script = bsv_rs::script::Script::from_binary(bytes)
823 .map_err(|e| format!("{label} script does not parse: {e}"))?;
824 let chunks = script.chunks();
825 for (i, chunk) in chunks.iter().enumerate() {
826 match chunk.op {
827 OP_RIPEMD160 | OP_SHA1 | OP_SHA256 | OP_HASH160 | OP_HASH256 => hash_ops += 1,
828 OP_CHECKSIG | OP_CHECKSIGVERIFY => sig_ops += 1,
829 OP_CHECKMULTISIG | OP_CHECKMULTISIGVERIFY => {
830 let stated = i
831 .checked_sub(1)
832 .map(|j| chunks[j].op)
833 .and_then(|op| match op {
834 OP_1..=OP_16 => Some((op - OP_1 + 1) as usize),
835 _ => None,
836 });
837 sig_ops += stated.unwrap_or(most_keys);
838 }
839 _ => {}
840 }
841 }
842 }
843 Ok((unlocking.len() + locking.len(), hash_ops, sig_ops))
844}
845
846impl Engine {
847 /// Page size used by `/requestSyncResponse` when the (public) caller
848 /// omits `limit`. A bounded page is not lossy for a conforming
849 /// initiator: ours ([`crate::gasp::GASPSync`]) keeps requesting pages
850 /// for as long as the `since` score cursor advances (gate finding M1 —
851 /// it must NOT require a "full" page to continue, because this clamp
852 /// makes a full-by-the-initiator's-limit page impossible).
853 pub const SYNC_RESPONSE_DEFAULT_LIMIT: u64 = 500;
854 /// Hard cap on a `/requestSyncResponse` page. Before bsv-low #291 an
855 /// absent/huge caller-supplied limit meant an unbounded scan of the
856 /// outputs table on a public route.
857 pub const SYNC_RESPONSE_MAX_LIMIT: u64 = 1000;
858
859 /// Clamp the public `/requestSyncResponse` page size: absent → the
860 /// default page; anything larger than the cap → the cap. The result is
861 /// ALWAYS `Some`-worthy — a sync response is never unbounded (bsv-low
862 /// #291). Completeness relies on the INITIATOR paging while its `since`
863 /// cursor advances (see `SYNC_RESPONSE_DEFAULT_LIMIT`'s doc) — the
864 /// responder alone cannot page for it.
865 pub fn clamp_sync_limit(requested: Option<u64>) -> u64 {
866 requested
867 .unwrap_or(Self::SYNC_RESPONSE_DEFAULT_LIMIT)
868 .min(Self::SYNC_RESPONSE_MAX_LIMIT)
869 }
870
871 /// Create a new Overlay Services Engine.
872 pub fn new(
873 managers: HashMap<String, Box<dyn TopicManager>>,
874 lookup_services: HashMap<String, Box<dyn LookupService>>,
875 storage: Box<dyn Storage>,
876 advertiser: Option<Box<dyn Advertiser>>,
877 config: EngineConfig,
878 ) -> Self {
879 Self::with_chain_tracker(
880 managers,
881 lookup_services,
882 storage,
883 advertiser,
884 None,
885 None,
886 config,
887 )
888 }
889
890 /// Create a new Engine with an optional ChainTracker for SPV verification.
891 pub fn with_chain_tracker(
892 managers: HashMap<String, Box<dyn TopicManager>>,
893 lookup_services: HashMap<String, Box<dyn LookupService>>,
894 storage: Box<dyn Storage>,
895 advertiser: Option<Box<dyn Advertiser>>,
896 broadcaster: Option<Box<dyn Broadcaster>>,
897 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
898 config: EngineConfig,
899 ) -> Self {
900 Self::with_all(
901 managers,
902 lookup_services,
903 storage,
904 advertiser,
905 broadcaster,
906 None,
907 chain_tracker,
908 config,
909 )
910 }
911
912 /// Create a new Engine with all optional components.
913 #[allow(clippy::too_many_arguments)]
914 pub fn with_all(
915 managers: HashMap<String, Box<dyn TopicManager>>,
916 lookup_services: HashMap<String, Box<dyn LookupService>>,
917 storage: Box<dyn Storage>,
918 advertiser: Option<Box<dyn Advertiser>>,
919 broadcaster: Option<Box<dyn Broadcaster>>,
920 arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
921 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
922 mut config: EngineConfig,
923 ) -> Self {
924 // Build default sync configuration: SHIP for all topics except tm_ship/tm_slap
925 // which get their trackers merged with provided shipTrackers/slapTrackers.
926 for manager_name in managers.keys() {
927 if manager_name == "tm_ship" {
928 if matches!(
929 config.sync_configuration.get(manager_name),
930 Some(SyncTarget::Disabled)
931 ) {
932 continue;
933 }
934 let mut combined: HashSet<String> = HashSet::new();
935 if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
936 {
937 combined.extend(peers.iter().cloned());
938 }
939 combined.extend(config.ship_trackers.iter().cloned());
940 if !combined.is_empty() {
941 config.sync_configuration.insert(
942 manager_name.clone(),
943 SyncTarget::Peers(combined.into_iter().collect()),
944 );
945 }
946 } else if manager_name == "tm_slap" {
947 if matches!(
948 config.sync_configuration.get(manager_name),
949 Some(SyncTarget::Disabled)
950 ) {
951 continue;
952 }
953 let mut combined: HashSet<String> = HashSet::new();
954 if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
955 {
956 combined.extend(peers.iter().cloned());
957 }
958 combined.extend(config.slap_trackers.iter().cloned());
959 if !combined.is_empty() {
960 config.sync_configuration.insert(
961 manager_name.clone(),
962 SyncTarget::Peers(combined.into_iter().collect()),
963 );
964 }
965 } else if !config.sync_configuration.contains_key(manager_name) {
966 config
967 .sync_configuration
968 .insert(manager_name.clone(), SyncTarget::Ship);
969 }
970 }
971
972 Self {
973 managers,
974 lookup_services,
975 storage,
976 advertiser,
977 broadcaster,
978 arc_broadcaster,
979 chain_tracker,
980 gasp_remote_factory: None,
981 ship_lookup_client: None,
982 ship_bootstrap_peers: HashMap::new(),
983 ancestor_fetcher: None,
984 peer_sync_budget: None,
985 graph_budget: None,
986 graph_budget_limbs: (
987 crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES,
988 crate::gasp::DEFAULT_GRAPH_BUDGET_NODES,
989 ),
990 finalize_submit_budget: None,
991 finalize_gate: crate::gasp::SubmitGate::default(),
992 not_landed: std::cell::RefCell::new(HashSet::new()),
993 landing_guard: None,
994 verify_scripts: true,
995 walk_budget: DoorBudget::DEFAULT,
996 config,
997 }
998 }
999
1000 /// Set the ARC broadcaster for network broadcast to miners.
1001 ///
1002 /// When set, the Engine broadcasts transactions to ARC during Phase 2
1003 /// of `submit()` for `CurrentTx` submissions.
1004 pub fn set_arc_broadcaster(&mut self, arc: Box<dyn ArcBroadcaster>) {
1005 self.arc_broadcaster = Some(arc);
1006 }
1007
1008 /// Set the GASP remote factory for peer communication during sync.
1009 ///
1010 /// Platform-specific crates (like overlay-cloudflare) provide an implementation
1011 /// that creates HTTP-based remotes using their native fetch API.
1012 pub fn set_gasp_remote_factory(&mut self, factory: Box<dyn crate::gasp::GASPRemoteFactory>) {
1013 self.gasp_remote_factory = Some(factory);
1014 }
1015
1016 /// Set the platform lookup transport for network SHIP discovery.
1017 /// Trackers come from [`EngineConfig::slap_trackers`].
1018 pub fn set_ship_lookup_client(
1019 &mut self,
1020 client: Box<dyn crate::ship_lookup::ShipLookupClient>,
1021 ) {
1022 self.ship_lookup_client = Some(client);
1023 }
1024
1025 /// Keep these peers in a topic's SHIP union even when trackers fail.
1026 /// This leaves the existing [`SyncTarget`] serialization unchanged.
1027 /// Such topics run ahead of tracker imports so discovery and sync can
1028 /// proceed without spending the tick on importing discovery records.
1029 pub fn set_ship_bootstrap_peers(&mut self, topic: impl Into<String>, peers: Vec<String>) {
1030 self.ship_bootstrap_peers.insert(topic.into(), peers);
1031 }
1032
1033 /// Set the OPT-IN chain-backed ancestor fetcher for GASP ingest.
1034 ///
1035 /// **OFF BY DEFAULT.** When set, a peer's failure to serve a needed
1036 /// ancestor during GASP ingest falls back to fetching that ancestor's raw
1037 /// tx from chain (via the supplied fetcher), self-healing the graph and
1038 /// also enabling strict-BEEF finalize so missing ancestry fails loud.
1039 ///
1040 /// This is a deliberate one-time-migration escape hatch. Production must
1041 /// NOT call this — when unset, GASP ingest behavior is unchanged.
1042 pub fn set_ancestor_fetcher(&mut self, fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>) {
1043 self.ancestor_fetcher = Some(fetcher);
1044 }
1045
1046 /// Set the admission predicate asked BEFORE the door lands a carried
1047 /// predecessor (bsv-low #575, the E1D delta-2 fold, L2;
1048 /// [`Engine::land_carried`]). It is asked once per body the landing
1049 /// would submit, before that body's submit (a dry one included) and
1050 /// before its reads are charged; an `Err` (the caller's refusal, or its
1051 /// own read fault) ends the landing with nothing of that body written
1052 /// and the successor's topic answers "not now", as on `683dffd`. The
1053 /// worker installs its eviction ledger here: an OPEN eviction of the
1054 /// body refuses it. Its read is not one of the submit's 256 (it is the
1055 /// caller's store, at most one per landed body). Unset (default): every
1056 /// carried predecessor may land. What the predicate cannot see (a row
1057 /// opened while the landing writes) stays the caller's to guard after
1058 /// the write ([`MutationReport::landed_predecessors`]).
1059 pub fn set_landing_guard(&mut self, guard: LandingGuard) {
1060 self.landing_guard = Some(guard);
1061 }
1062
1063 /// Set the per-peer GASP sync budget (bsv-low#302).
1064 ///
1065 /// `sleep` is the platform sleep factory (ms → future); `budget_ms` is
1066 /// the wall-clock slice ONE peer's sync may consume. A peer exceeding it
1067 /// is dropped (loudly logged, listed in the topic's `errors`) and the
1068 /// loop moves on. Unset (default) = unbounded, the pre-#302 behavior.
1069 ///
1070 /// **Progress survives the deadline (bsv-low #552).** With a budget set,
1071 /// every graph is submitted AS IT FINALIZES, inside the raced future (the
1072 /// reference submits inside `finalizeGraph` too), so what was finalized
1073 /// before the deadline stays admitted. At the deadline the cursor is
1074 /// advanced to `GASPSync::completed_cursor`: past the UTXOs whose graphs
1075 /// were completed, never past the one in flight. That graph is lost
1076 /// whole (nothing of it was finalized) and the next tick walks it again,
1077 /// down to whatever is now in storage.
1078 ///
1079 /// **The deadline never lands inside a transaction's writes** (the lens
1080 /// fold's HIGH-1). It is cooperative around a finalize submit
1081 /// ([`Engine::finalize_submit_gate`]): a deadline that falls due while
1082 /// one transaction is being written waits for that transaction, and the
1083 /// sync is dropped at the boundary. What a dropped tick leaves of the
1084 /// graph being submitted is an ancestors-first prefix of WHOLE
1085 /// transactions, with the cursor below its UTXO. Everywhere else (a
1086 /// request to the peer, the walk, the anchor check) the deadline drops
1087 /// the sync at once, as a budget against a dead peer must. A dropped tick that finalized a
1088 /// graph or moved the cursor is recorded as a SUCCESSFUL attempt for the
1089 /// quarantine count: a peer serving a long bootstrap is not a dead peer.
1090 /// One with no progress is a failed attempt, as before.
1091 ///
1092 /// The budget bounds the work of a TICK. Without a per-graph budget
1093 /// nothing bounds a GRAPH (parity: the reference has no node cap): one
1094 /// graph whose own walk outlasts the budget is dropped on every tick
1095 /// (`deadline_dropped_graphs`) and never admitted. With one
1096 /// ([`Engine::set_graph_budget`], bsv-low #555) that walk is deferred
1097 /// with its progress kept, here too, and resumed next tick.
1098 pub fn set_peer_sync_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1099 self.peer_sync_budget = Some((sleep, budget_ms));
1100 }
1101
1102 /// Set the per-GRAPH GASP budget and turn DEFERRAL on (bsv-low #555).
1103 ///
1104 /// A graph whose walk makes `max_calls` requests (to the peer, or chain
1105 /// fetches) or runs `budget_ms` in one pass is DEFERRED: its partial walk
1106 /// (the nodes fetched with their proofs, the inputs still pending, the
1107 /// calls spent, its age in passes) is saved as ONE record of the storage
1108 /// ([`Storage::put_deferred_graph`], replaced on every deferral), its
1109 /// UTXO is held below the cursor as a failed one is (the gap guard) and
1110 /// not walked again in the sync (#554), and the sync goes on to the next
1111 /// UTXO and topic. The next sync that is served that UTXO RESUMES the
1112 /// walk from the record, asking only what is pending, under the same
1113 /// budget; a graph so converges over passes and is then completed as
1114 /// any graph is (#551's anchor check, the finalize submits, e1d's
1115 /// rules). A walk cut by the per-peer deadline is deferred the same way.
1116 ///
1117 /// Bounds: a record deferred [`crate::gasp::DEFERRED_GRAPH_MAX_PASSES`]
1118 /// times is dropped with its reason and the UTXO fails as before (the
1119 /// gap guard asks again, from the root); a record has no byte bound
1120 /// (bsv-low #585); at most
1121 /// [`crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC`] records per (peer,
1122 /// topic). Keep `budget_ms` below the per-peer budget so that a deep
1123 /// graph leaves the pass time for the UTXOs after it. Defaults offered:
1124 /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_CALLS`],
1125 /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_MS`].
1126 ///
1127 /// Every resume of one sync shares ONE such budget (the lens fold's M1),
1128 /// so the deferred UTXOs, served first, leave the pass the rest; a walk
1129 /// that fetched nothing keeps no record and is a failed attempt for the
1130 /// quarantine (H1); a walk that cannot be kept, or whose save faulted,
1131 /// goes on under the per-peer budget alone (L4, the delta fold's D-L2);
1132 /// a FRESH walk's error keeps no record (D-M1). With a budget, a sync the
1133 /// peer served work that finalized no graph and moved no cursor is
1134 /// YIELDLESS, and past [`crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED`] in a
1135 /// row AND [`crate::gasp::PEER_YIELDLESS_SECS_ALLOWED`] since the streak
1136 /// began each is a failed attempt (D-M2, the delta-2 fold's D2-M1; the
1137 /// streak is kept by [`Storage::record_peer_sync_yield`], keyed on the
1138 /// peer's [`crate::gasp::peer_origin`] as its quarantine is). A FRESH
1139 /// walk that faults after half its calls or half its time keeps its
1140 /// record (D2-L1); a RESUMED one that faults having appended nothing
1141 /// [`crate::gasp::DEFERRED_GRAPH_MAX_IDLE_FAULTS`] passes in a row is
1142 /// dropped (D2-M2). Each record says whether its peer is configured
1143 /// (`SyncTarget::Peers`), for a storage that reserves part of its
1144 /// ceiling for those (the worker reserves half).
1145 ///
1146 /// Unset (the default) nothing is deferred and the walk is the one
1147 /// before #555 (parity: the reference has no budget and no deferral).
1148 /// The storage must KEEP records (the four required deferred-graph
1149 /// methods of [`Storage`]): over one that refuses every save, every
1150 /// graph past the budget is walked from its root under the per-peer
1151 /// budget alone on every pass, as before #555, so do not set a budget
1152 /// there.
1153 ///
1154 /// The budget has two more limbs (bsv-low #586), in force with it at
1155 /// their defaults: the bytes one graph may be SERVED in one pass
1156 /// ([`crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES`], a budget per pass only:
1157 /// a record has no byte bound since bsv-low #585 door 4) and the nodes it
1158 /// may APPEND ([`crate::gasp::DEFAULT_GRAPH_BUDGET_NODES`], 64);
1159 /// [`Engine::set_graph_budget_limbs`] names others.
1160 pub fn set_graph_budget(&mut self, sleep: SleepFactory, max_calls: u32, budget_ms: u64) {
1161 self.graph_budget = Some((sleep, max_calls, budget_ms));
1162 }
1163
1164 /// The bytes and nodes limbs of the per-graph budget (bsv-low #586,
1165 /// zanaadu-v2 #377): a graph whose walk is SERVED `max_bytes_fetched`
1166 /// bytes in one pass (the hex of each node's raw transaction and proof,
1167 /// as the peer sends it and a record keeps it, every answer counted), or
1168 /// APPENDS `max_nodes` nodes, is DEFERRED exactly as at the call budget
1169 /// (reason `bytes` or `nodes`): its record saved, its UTXO held below
1170 /// the cursor, the walk resumed by the next pass from what is pending.
1171 /// Never a drop, a refusal or a discarded graph: these are budgets PER
1172 /// PASS, not limits on a graph or a BEEF. A pass always makes one
1173 /// request whatever a node's size; the resumes of one sync share one
1174 /// such budget, as they share its calls (M1); a walk that cannot be
1175 /// KEPT goes on under the per-peer budget alone (L4), as at the calls.
1176 /// They bound what one pass adds to the pending graph and to its record
1177 /// while the anchor check and the finalize hold it in memory. No effect
1178 /// until [`Engine::set_graph_budget`] turns deferral on; the reference
1179 /// has neither. This setter is UNCAPPED, as the worker's
1180 /// `GASP_GRAPH_BUDGET_BYTES` var is (bsv-low #585 door 4: the engine keeps
1181 /// no record cap for the limb to sit under). A consumer whose STORAGE caps
1182 /// a record must keep the bytes limb under that cap, or a pass whose
1183 /// record cannot be saved walks afresh (E586-L1).
1184 pub fn set_graph_budget_limbs(&mut self, max_bytes_fetched: u64, max_nodes: u32) {
1185 self.graph_budget_limbs = (max_bytes_fetched, max_nodes);
1186 }
1187
1188 /// Bound ONE transaction's GASP finalize submit (bsv-low #559, the delta
1189 /// lens's DELTA-3). That submit is the write section no deadline drops
1190 /// ([`Engine::finalize_submit_gate`]), so a storage call or lookup hook
1191 /// that never answers inside it held the per-peer budget and any outer
1192 /// race for as long as it hung. With this set EACH storage call, lookup
1193 /// hook and topic manager call of that submit is raced against one
1194 /// `sleep(budget_ms)` of the section (the lens fold of 2026-10-07, F2):
1195 /// a call still unanswered when it falls due is dropped alone and is
1196 /// that call's FAULT, and no call is started after it. The SUBMIT is
1197 /// never dropped: it runs to its end and leaves what a fault leaves
1198 /// ([`Engine::submit_with_report`]: nothing of the transaction, or all
1199 /// of it), the transaction did not land, the rest of its graph is not
1200 /// submitted, its UTXO fails and the cursor stays below it. The undo of
1201 /// a faulted submit has one more `sleep(budget_ms)` of its own, so a
1202 /// peer costs at most its budget plus twice this one. What the backend
1203 /// does with a statement whose caller stopped waiting is its own: a
1204 /// write that lands after its timeout is not seen by this submit.
1205 ///
1206 /// DEFAULT: none (a native engine has no clock). A caller that sets a
1207 /// per-peer budget should set this too.
1208 pub fn set_finalize_submit_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1209 self.finalize_submit_budget = Some((sleep, budget_ms));
1210 }
1211
1212 /// The gate around every GASP finalize submit (bsv-low #552, the lens
1213 /// fold's HIGH-1). `start_gasp_sync` races each peer against its budget
1214 /// with [`crate::gasp::race_or_deadline_guarded`] over this gate. A
1215 /// caller that races `start_gasp_sync` ITSELF against a deadline (the
1216 /// worker's scheduled step) must use the same function over the same
1217 /// gate: a plain `race_or_deadline` there can still drop a finalize
1218 /// submit between its writes.
1219 pub fn finalize_submit_gate(&self) -> &crate::gasp::SubmitGate {
1220 &self.finalize_gate
1221 }
1222
1223 /// Turn reference-parity script verification on submit on or off.
1224 /// **DEFAULT ON** (set in every constructor).
1225 ///
1226 /// On, every submit outside `HistoricalTxNoSpv` executes every unproven
1227 /// input's unlocking script against its source output (and checks every
1228 /// merkle path against the chain tracker), exactly as the reference's
1229 /// `Engine.submit` → `tx.verify(this.chainTracker)` does. Off is an
1230 /// ESCAPE HATCH for an operator who must admit a body the interpreter
1231 /// refuses (a bsv-rs interpreter defect, say) while it is fixed. It is
1232 /// NOT a mode: it restores the pre-2026-09-08 structural check, under
1233 /// which an invalid spend that no broadcaster had yet refused was
1234 /// admitted on BEEF structure alone. Prefer
1235 /// [`crate::builder::EngineBuilder::with_script_verification`].
1236 pub fn set_script_verification(&mut self, enabled: bool) {
1237 self.verify_scripts = enabled;
1238 }
1239
1240 /// Whether submits execute input scripts, see
1241 /// [`Engine::set_script_verification`]. `true` by default.
1242 pub fn script_verification(&self) -> bool {
1243 self.verify_scripts
1244 }
1245
1246 /// The budget of the submit's own script walk (bsv-low #592, the owner's
1247 /// ruling 3a of 2026-10-10). Every submit that walks (every mode but
1248 /// `HistoricalTxNoSpv`), and the GASP anchor check, read the BEEF through
1249 /// the door's stream walk ([`crate::script_door`]) under this budget's
1250 /// two limbs, the work and the memory, each estimated from the bytes
1251 /// before it is spent. A breach is "the walk could not run"
1252 /// ([`WalkCouldNotRun`], on [`MutationReport::walk_could_not_run`]),
1253 /// never a refusal: the submit goes on as under `HistoricalTxNoSpv`.
1254 /// A library consumer that sets nothing gets [`DoorBudget::DEFAULT`]
1255 /// (64 MiB of work, 48 MiB of memory). Prefer
1256 /// [`crate::builder::EngineBuilder::with_walk_budget`].
1257 pub fn set_walk_budget(&mut self, budget: DoorBudget) {
1258 self.walk_budget = budget;
1259 }
1260
1261 /// The budget of the submit's own script walk, see
1262 /// [`Engine::set_walk_budget`].
1263 pub fn walk_budget(&self) -> DoorBudget {
1264 self.walk_budget
1265 }
1266
1267 /// The reference's `tx.verify('scripts only')` over a BEEF: every unproven
1268 /// transaction from `subject_txid` down executes every input's unlocking
1269 /// script against its source output and obeys the value rule, while a
1270 /// transaction that carries a merkle path is TRUSTED AS-IS — no root is
1271 /// computed and no chain tracker is consulted (the ts-sdk's own shape when
1272 /// `chainTracker === 'scripts only'`: a proven tx is added to the verified
1273 /// set and the walk stops there).
1274 ///
1275 /// Built for an admission path whose bar is the NETWORK, not a proof
1276 /// (bsv-low's `broadcast-gated`, register row D1): the overlay broadcasts
1277 /// and admits on network evidence, so a lagging tracker must never read as
1278 /// a refused transaction — but a spend the interpreter refuses is one the
1279 /// network will refuse too, and executing it at the door saves the
1280 /// broadcast and names the fault. Independent of
1281 /// [`Engine::set_script_verification`] (that switch governs `submit`; this
1282 /// is an explicit ask) and of [`Engine::submit`]'s mode (`HistoricalTxNoSpv`
1283 /// still skips, as the reference does).
1284 ///
1285 /// Runs under the engine's budget ([`Engine::set_walk_budget`], the
1286 /// submit's own; [`DoorBudget::DEFAULT`] unless set: one budget per
1287 /// engine, the E592 lens fold's NOTE-2): the work is bounded statically
1288 /// before anything executes, and the interpreter's stack memory limit is
1289 /// the budget's element limit. The body is read through the streaming
1290 /// reader ([`crate::script_door`], bsv-low #585): no bound on its size,
1291 /// its transactions or a transaction's inputs or bytes; the budget's two
1292 /// limbs are the work and the memory beside the body.
1293 ///
1294 /// Errors: [`EngineError::ScriptVerificationFailed`] is the INTERPRETER's
1295 /// verdict and the only refusal; [`EngineError::ScriptWalkInconclusive`]
1296 /// is a structural fault (a transaction or source missing from the BEEF,
1297 /// a parse fault, the value rule) and [`EngineError::ScriptWalkOverBudget`]
1298 /// the door's own bound (the static budget or the memory limit) — both
1299 /// name whether the SUBJECT was judged before the fault, and a caller with
1300 /// a stronger bar behind it does not refuse on either.
1301 #[allow(
1302 clippy::unused_async,
1303 reason = "the door's callers await it on an engine; 'scripts only' asks no tracker"
1304 )]
1305 pub async fn verify_scripts_only(
1306 &self,
1307 beef_bytes: &[u8],
1308 subject_txid: &str,
1309 ) -> Result<WalkStats, EngineError> {
1310 crate::script_door::walk(beef_bytes, subject_txid, self.walk_budget)
1311 }
1312
1313 /// [`Engine::verify_scripts_only`] under a budget the caller names (the
1314 /// pins measure the door with a limb lifted; a caller with another
1315 /// platform's ceiling sets its own).
1316 #[allow(
1317 clippy::unused_async,
1318 clippy::unused_self,
1319 reason = "the twin of `verify_scripts_only`"
1320 )]
1321 pub async fn verify_scripts_only_under(
1322 &self,
1323 beef_bytes: &[u8],
1324 subject_txid: &str,
1325 budget: DoorBudget,
1326 ) -> Result<WalkStats, EngineError> {
1327 crate::script_door::walk(beef_bytes, subject_txid, budget)
1328 }
1329
1330 // ========================================================================
1331 // Submit — 3-phase pipeline
1332 // ========================================================================
1333
1334 /// Validate a tagged BEEF without applying mutations.
1335 ///
1336 /// Runs Phase 1 (topic validation) and Phase 2 (broadcast) but NOT Phase 3
1337 /// (storage mutations). Returns the Steak (admittance decisions) that
1338 /// `submit()` would return.
1339 ///
1340 /// Used by the onSteakReady pattern to return results to clients before
1341 /// mutations are applied via a queue consumer or `ctx.wait_until()`.
1342 ///
1343 /// The topic managers are called with `dry_run: true`
1344 /// (`TopicAdmittanceContext::DRY_RUN`): nothing is admitted on this call, so a
1345 /// manager that writes on admission leaves no trace of it.
1346 pub async fn submit_validate_only(
1347 &self,
1348 tagged_beef: &TaggedBEEF,
1349 mode: SubmitMode,
1350 ) -> Result<Steak, EngineError> {
1351 // A validate-only call admits nothing: a dry run by its own name. A
1352 // manager that writes on admission must not write here, or the real
1353 // submit that follows meets a head this call already advanced.
1354 let (_validations, steak, _tx, _txid, _could_not_run) = self
1355 .run_validation(
1356 tagged_beef,
1357 mode,
1358 &TopicAdmittanceContext::DRY_RUN,
1359 None,
1360 None,
1361 WalkBreachPolicy::NotNow,
1362 )
1363 .await?;
1364 Ok(steak)
1365 }
1366
1367 /// Submit a transaction for processing by the overlay.
1368 ///
1369 /// Three phases:
1370 /// 1. **VALIDATE** — check topics, dedup, find previous coins, call topic managers
1371 /// 2. **BROADCAST** — broadcast to network (if mode is CurrentTx)
1372 /// 3. **MUTATE** — mark spent, delete stale, insert new outputs, notify lookup services
1373 ///
1374 /// Returns a STEAK mapping each topic to its admittance instructions.
1375 ///
1376 /// Phase-3 faults are reported through [`Engine::submit_with_report`];
1377 /// this convenience keeps the historical signature and only LOGS the
1378 /// report's summary. A caller that must not ack an admission whose
1379 /// writes did not land (the `/submit` route — S2) uses the report form.
1380 pub async fn submit(
1381 &self,
1382 tagged_beef: &TaggedBEEF,
1383 mode: SubmitMode,
1384 ) -> Result<Steak, EngineError> {
1385 let (steak, report) = self.submit_with_report(tagged_beef, mode).await?;
1386 if !report.is_durable() {
1387 warn!(
1388 "submit: {} mutation fault(s) — not durable: {}",
1389 report.faults.len(),
1390 report.summary()
1391 );
1392 }
1393 Ok(steak)
1394 }
1395
1396 /// [`Engine::submit`] plus the Phase-3 durability report (S2
1397 /// queue-durable admission, bsv-low 2026-08-29).
1398 ///
1399 /// The STEAK is the admission DECISION; the report says whether the
1400 /// decision was DURABLY WRITTEN. They are returned together because
1401 /// they are answered by different things: Phase 1+2 decide, Phase 3
1402 /// lands, and only the caller knows whether an undurable landing may
1403 /// be acked (never, on the money path — re-queue it instead).
1404 ///
1405 /// Every write failure is still logged AND still non-fatal to the
1406 /// other topics (a faulted topic does not un-admit its siblings); what
1407 /// changed is that the failure is also VISIBLE, and that the faulted
1408 /// topic is not recorded as applied — so a replay of the same bytes is
1409 /// re-validated and re-written (every backend write is idempotent:
1410 /// `INSERT OR IGNORE` / `OR REPLACE` / `UPDATE`).
1411 ///
1412 /// What a fault leaves (bsv-low #559 and its lens fold of 2026-10-07):
1413 /// a topic's previous coins are deleted once EVERY admitted output of
1414 /// the transaction is inserted, and only then. A fault before that
1415 /// (a validation read, the spent mark of a coin the manager retains, an
1416 /// insert) leaves the previous coins held and nothing of the
1417 /// transaction: what did get inserted is taken out again, so the replay
1418 /// is the same judgement and no successor can spend half of it. A fault
1419 /// after it (a lookup notification, the consumed-by update, the applied
1420 /// row) leaves the transaction's outputs and no previous coin. A fault
1421 /// before any delete was started (the record of the spent coin's
1422 /// transaction, the read of the coin) is the first case if the coins
1423 /// are read back still held; once a delete was started its fault never
1424 /// undoes, answered or not (it may land yet): the outputs stay beside
1425 /// the stale coin, marked spent. A submit that deletes a stale coin
1426 /// first records that coin's transaction as applied, so a replay of it
1427 /// after its successor is a dupe. So a non-retaining chain never has
1428 /// two unspent heads and never none, whatever single call faults. The
1429 /// applied row is withheld on ANY fault. And a successor that was
1430 /// judged without the coin an unlanded predecessor has yet to leave is
1431 /// reported as a fault (`predecessor_not_landed`), not recorded as
1432 /// applied.
1433 ///
1434 /// A walk that could not run within the engine's budget is "not now"
1435 /// here ([`WalkBreachPolicy::NotNow`], [`EngineError::WalkCouldNotRun`]):
1436 /// never an admission. [`Engine::submit_with_report_under`] names the
1437 /// policy.
1438 pub async fn submit_with_report(
1439 &self,
1440 tagged_beef: &TaggedBEEF,
1441 mode: SubmitMode,
1442 ) -> Result<(Steak, MutationReport), EngineError> {
1443 self.submit_with_report_under(tagged_beef, mode, WalkBreachPolicy::NotNow)
1444 .await
1445 }
1446
1447 /// [`Engine::submit_with_report`] under an explicit
1448 /// [`WalkBreachPolicy`] (the E592 lens fold, H1): what a walk that could
1449 /// not run within the budget does. `NetworkAccepted` is for a caller that
1450 /// holds the network's accept of these very bytes (the queue's replay of
1451 /// a gated submission) and for nothing else: it admits a breached body
1452 /// with its scripts unrun.
1453 pub async fn submit_with_report_under(
1454 &self,
1455 tagged_beef: &TaggedBEEF,
1456 mode: SubmitMode,
1457 policy: WalkBreachPolicy,
1458 ) -> Result<(Steak, MutationReport), EngineError> {
1459 self.submit_bounded(tagged_beef, mode, None, false, policy)
1460 .await
1461 }
1462
1463 /// [`Engine::submit_with_report`] with every storage call, lookup hook
1464 /// and topic manager call under `bound` (the GASP finalize submit,
1465 /// [`CallBound`]); `None` is the unbounded submit of every other door.
1466 /// `finalize` is `true` for the GASP finalize submit alone, bounded or
1467 /// not: it does not ask the store's predecessor question.
1468 async fn submit_bounded(
1469 &self,
1470 tagged_beef: &TaggedBEEF,
1471 mode: SubmitMode,
1472 bound: Option<&CallBound>,
1473 finalize: bool,
1474 policy: WalkBreachPolicy,
1475 ) -> Result<(Steak, MutationReport), EngineError> {
1476 // The reads the store's predecessor question has made in this
1477 // submit, every topic together ([`PREDECESSOR_READS_PER_SUBMIT`]),
1478 // the predecessors the door lands first from the BEEF included.
1479 let mut question_reads = 0usize;
1480 self.submit_counted(
1481 tagged_beef,
1482 mode,
1483 bound,
1484 finalize,
1485 None,
1486 &mut question_reads,
1487 policy,
1488 )
1489 .await
1490 .map(|(steak, report, _)| (steak, report))
1491 }
1492
1493 /// [`Engine::submit_bounded`] over the submit's read allowance
1494 /// (`question_reads`), which a predecessor the door lands first from the
1495 /// BEEF shares with the successor that carried it (lane E1D's lens fold,
1496 /// M1). `carried` is `Some` for such a predecessor's own submit
1497 /// ([`Engine::land_carried`]), holding the bodies whose SPV walk already
1498 /// passed in this submit (none is walked twice; the delta fold, L4): it
1499 /// lands nothing first itself, and it names instead the carried
1500 /// predecessor that blocks each topic (the third value), so the landing
1501 /// is an explicit stack and never a recursion of submits (nested polls
1502 /// grow the stack, and a Worker's is small). Its manager is asked as a
1503 /// DRY RUN first, and for real only once its topic is not blocked: a
1504 /// link blocked below is judged twice and admitted once. Boxed: the
1505 /// landing is a submit of its own.
1506 #[allow(clippy::too_many_arguments)]
1507 fn submit_counted<'a>(
1508 &'a self,
1509 tagged_beef: &'a TaggedBEEF,
1510 mode: SubmitMode,
1511 bound: Option<&'a CallBound>,
1512 finalize: bool,
1513 carried: Option<&'a HashSet<String>>,
1514 question_reads: &'a mut usize,
1515 policy: WalkBreachPolicy,
1516 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Counted, EngineError>> + 'a>>
1517 {
1518 Box::pin(async move {
1519 self.submit_counted_inner(
1520 tagged_beef,
1521 mode,
1522 bound,
1523 finalize,
1524 carried,
1525 question_reads,
1526 policy,
1527 )
1528 .await
1529 })
1530 }
1531
1532 #[allow(clippy::too_many_arguments)]
1533 async fn submit_counted_inner(
1534 &self,
1535 tagged_beef: &TaggedBEEF,
1536 mode: SubmitMode,
1537 bound: Option<&CallBound>,
1538 finalize: bool,
1539 walked: Option<&HashSet<String>>,
1540 question_reads: &mut usize,
1541 policy: WalkBreachPolicy,
1542 ) -> Result<Counted, EngineError> {
1543 // A submit is a real admission, never a dry run; a carried
1544 // predecessor's is judged dry until its topic is known not to wait.
1545 let carried = walked.is_some();
1546 let context = if carried {
1547 TopicAdmittanceContext::DRY_RUN
1548 } else {
1549 TopicAdmittanceContext::default()
1550 };
1551 let (mut validations, mut steak, tx, txid, could_not_run) = self
1552 .run_validation(tagged_beef, mode, &context, bound, walked, policy)
1553 .await?;
1554 let mut report = MutationReport {
1555 walk_could_not_run: could_not_run,
1556 ..MutationReport::default()
1557 };
1558 // The body every LOOKUP SERVICE receives NAMES the subject (BRC-95
1559 // atomic prefix over the WHOLE submitted body — bsv-rs's
1560 // `to_binary_atomic` sorts, names, never prunes). Lookup services
1561 // re-parse it with `from_beef(_, None)`; on a plain incomplete body
1562 // that pick is the wire-last ANCESTOR, so the pot index's admit hook
1563 // shape-checked a hop and wrote no record while the engine had
1564 // admitted the pot (loop-2 fleet, 2026-09-05; the live proof after
1565 // the engine's own subject fix). A body that cannot be re-serialized
1566 // (never, for a parsed subject) is handed through as submitted.
1567 let subject_named_beef: Vec<u8> =
1568 beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
1569 .ok()
1570 .and_then(|mut b| b.to_binary_atomic(&txid).ok())
1571 .unwrap_or_else(|| tagged_beef.beef.clone());
1572
1573 // The successor rule, asked of every topic that found no coin before
1574 // anything of this submit is written ([`Engine::successors_waiting`]):
1575 // the topics that must wait, each with why.
1576 let mut blocked: HashMap<String, String> = HashMap::new();
1577 // The landing trusts the subject's walk only if it RAN (the E592
1578 // lens fold, M1): after a breach nothing it reaches was walked.
1579 let landing = Landing {
1580 policy,
1581 subject_walk_ran: report.walk_could_not_run.is_none(),
1582 };
1583 let waits = self
1584 .successors_waiting(
1585 &tx,
1586 &txid,
1587 &subject_named_beef,
1588 &mut validations,
1589 &mut steak,
1590 tagged_beef.off_chain_values.as_deref(),
1591 mode,
1592 bound,
1593 finalize,
1594 !carried,
1595 &mut blocked,
1596 question_reads,
1597 &mut report.landed_predecessors,
1598 landing,
1599 &mut report.landed_walks_could_not_run,
1600 )
1601 .await;
1602 // A carried predecessor's topic that does not wait is judged again,
1603 // for real: what it admits is this call's word, over the same coins.
1604 if carried {
1605 for v in &mut validations {
1606 if v.is_dupe || v.failed || v.read_fault.is_some() || waits.contains_key(&v.topic) {
1607 continue;
1608 }
1609 match self
1610 .judge(
1611 &tx,
1612 &v.topic,
1613 &v.previous_coins,
1614 tagged_beef.off_chain_values.as_deref(),
1615 mode,
1616 &TopicAdmittanceContext::default(),
1617 bound,
1618 )
1619 .await
1620 {
1621 Ok(admittance) => v.admittance = admittance,
1622 Err(e) => {
1623 error!("Error validating topic {} during submit: {e}", v.topic);
1624 v.failed = true;
1625 v.previous_coins.clear();
1626 v.previous_outputs.clear();
1627 v.admittance = AdmittanceInstructions::default();
1628 }
1629 }
1630 steak.insert(v.topic.clone(), v.admittance.clone());
1631 }
1632 }
1633
1634 // =================================================================
1635 // PHASE 3: MUTATE STORAGE
1636 // =================================================================
1637 for v in &validations {
1638 if v.is_dupe {
1639 report.deduped_topics.push(v.topic.clone());
1640 continue;
1641 }
1642 // A failed topic comes first, a read fault of it included: a
1643 // previous-coin read that faulted and a manager that then ERRED
1644 // is a failed topic with a durable report, no fault and no
1645 // replay. Parity: the reference fails the topic and never
1646 // replays. Nothing was written, so the store is as it was.
1647 if v.failed {
1648 continue;
1649 }
1650
1651 let topic = &v.topic;
1652 let admittance = &v.admittance;
1653 let faults_before = report.faults.len();
1654
1655 // A validation read that faulted: the manager judged without a
1656 // coin the store may hold, so NOTHING is written on that
1657 // judgement (the lens fold of 2026-10-07). Writing its outputs
1658 // would leave them beside the unseen coin, for a successor to
1659 // spend before the replay deletes it.
1660 if let Some((read, err)) = &v.read_fault {
1661 report.fault(topic, read, err.clone());
1662 self.hold_unapplied(&txid, topic, "a validation read faulted, nothing written");
1663 continue;
1664 }
1665
1666 // bsv-low #559 and lane E1D: a topic that found no coin while a
1667 // predecessor's landing is unknown is "not now", recorded
1668 // nowhere ([`Engine::successors_waiting`]).
1669 if let Some(not_now) = waits.get(topic) {
1670 report.fault(topic, "predecessor_not_landed", not_now.clone());
1671 self.hold_unapplied(
1672 &txid,
1673 topic,
1674 &format!("found no coin and its predecessor {not_now}"),
1675 );
1676 continue;
1677 }
1678
1679 // ── Handle stale vs retained previous coins ──
1680 let mut outputs_consumed: Vec<Outpoint> = Vec::new();
1681 let mut stale_coins: Vec<&Output> = Vec::new();
1682
1683 for (coin_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1684 let input_index = v.previous_coins[coin_idx];
1685 if admittance.coins_to_retain.contains(&input_index) {
1686 // Retained: track as consumed (will update consumedBy later)
1687 outputs_consumed
1688 .push(Outpoint::new(&prev_output.txid, prev_output.output_index));
1689 } else {
1690 // Not retained: mark as stale for deletion
1691 stale_coins.push(prev_output);
1692 }
1693 }
1694
1695 // Update STEAK with removed coins
1696 if let Some(steak_entry) = steak.get_mut(topic) {
1697 steak_entry.coins_removed = Some(
1698 stale_coins
1699 .iter()
1700 .enumerate()
1701 .filter_map(|(i, _)| {
1702 // Map back to input indices
1703 v.previous_coins.get(i).copied()
1704 })
1705 .collect(),
1706 );
1707 }
1708
1709 // ── Mark previous outputs as spent + notify lookup services ──
1710 // A mark that faults does not skip the notification (the
1711 // reference skips it): the coin is spent whatever the row says,
1712 // a stale one is deleted below and its lookup services must
1713 // know. The mark of a RETAINED coin is part of what landed: that
1714 // row stays, and left unspent it would be listed as a UTXO
1715 // beside the output that consumed it.
1716 let mut retained_mark_faulted = false;
1717 for (prev_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1718 if let Err(e) = stored(
1719 bound,
1720 self.storage.mark_utxo_as_spent(
1721 &prev_output.txid,
1722 prev_output.output_index,
1723 topic,
1724 ),
1725 )
1726 .await
1727 {
1728 error!("Error marking UTXO as spent: {e}");
1729 report.fault(topic, "mark_utxo_as_spent", e.to_string());
1730 retained_mark_faulted |= admittance
1731 .coins_to_retain
1732 .contains(&v.previous_coins[prev_idx]);
1733 }
1734
1735 // The input index within tx.inputs that spends this previous output.
1736 let spending_input_idx = v.previous_coins[prev_idx] as usize;
1737
1738 // Notify all lookup services about the spent output
1739 for ls in self.lookup_services.values() {
1740 let payload = match ls.spend_notification_mode() {
1741 SpendNotificationMode::None => OutputSpent::None {
1742 txid: prev_output.txid.clone(),
1743 output_index: prev_output.output_index,
1744 topic: topic.clone(),
1745 },
1746 SpendNotificationMode::Txid => OutputSpent::Txid {
1747 txid: prev_output.txid.clone(),
1748 output_index: prev_output.output_index,
1749 topic: topic.clone(),
1750 spending_txid: txid.clone(),
1751 },
1752 SpendNotificationMode::Script => {
1753 // Extract unlocking script and sequence from the spending input
1754 let input = &tx.inputs[spending_input_idx];
1755 let unlocking_script = input
1756 .unlocking_script
1757 .as_ref()
1758 .map(bsv_rs::UnlockingScript::to_binary)
1759 .unwrap_or_default();
1760 OutputSpent::Script {
1761 txid: prev_output.txid.clone(),
1762 output_index: prev_output.output_index,
1763 topic: topic.clone(),
1764 spending_txid: txid.clone(),
1765 input_index: spending_input_idx as u32,
1766 unlocking_script,
1767 sequence_number: input.sequence,
1768 off_chain_values: tagged_beef.off_chain_values.clone(),
1769 }
1770 }
1771 SpendNotificationMode::WholeTx => OutputSpent::WholeTx {
1772 txid: prev_output.txid.clone(),
1773 output_index: prev_output.output_index,
1774 topic: topic.clone(),
1775 spending_atomic_beef: subject_named_beef.clone(),
1776 off_chain_values: tagged_beef.off_chain_values.clone(),
1777 },
1778 };
1779 if let Err(e) = hooked(bound, ls.output_spent(&payload)).await {
1780 error!("Error notifying lookup service of spent output: {e}");
1781 report.fault(topic, "lookup_service.output_spent", e);
1782 }
1783 }
1784 }
1785 if retained_mark_faulted {
1786 self.hold_unapplied(
1787 &txid,
1788 topic,
1789 "the spent mark of a retained coin faulted, nothing inserted",
1790 );
1791 continue;
1792 }
1793
1794 // ── Insert admitted outputs ──
1795 // The inserts stop at the first one that faults: what follows is
1796 // the undo, not the rest of the transaction.
1797 let mut new_utxos: Vec<Outpoint> = Vec::new();
1798 let mut attempted: Vec<u32> = Vec::new();
1799 let mut insert_faulted = false;
1800
1801 for &output_index in &admittance.outputs_to_admit {
1802 let (script_bytes, sats) =
1803 if let Some(tx_output) = tx.outputs.get(output_index as usize) {
1804 (
1805 tx_output.locking_script.to_binary(),
1806 tx_output.get_satoshis(),
1807 )
1808 } else {
1809 (Vec::new(), 0)
1810 };
1811
1812 let output = Output {
1813 txid: txid.clone(),
1814 output_index,
1815 output_script: script_bytes,
1816 satoshis: sats,
1817 topic: topic.clone(),
1818 spent: false,
1819 outputs_consumed: outputs_consumed.clone(),
1820 consumed_by: Vec::new(),
1821 beef: Some(tagged_beef.beef.clone()),
1822 block_height: None,
1823 score: Some(current_timestamp_ms()),
1824 };
1825
1826 attempted.push(output_index);
1827 if let Err(e) = stored(bound, self.storage.insert_output(&output)).await {
1828 error!("Error inserting output for topic {topic}: {e}");
1829 report.fault(topic, "insert_output", e.to_string());
1830 insert_faulted = true;
1831 break;
1832 }
1833
1834 new_utxos.push(Outpoint::new(&txid, output_index));
1835
1836 // Notify lookup services
1837 for ls in self.lookup_services.values() {
1838 let (ls_script, ls_sats) =
1839 if let Some(tx_out) = tx.outputs.get(output_index as usize) {
1840 (tx_out.locking_script.to_binary(), tx_out.get_satoshis())
1841 } else {
1842 (Vec::new(), 0)
1843 };
1844
1845 let payload = match ls.admission_mode() {
1846 AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
1847 txid: txid.clone(),
1848 output_index,
1849 topic: topic.clone(),
1850 satoshis: ls_sats,
1851 locking_script: ls_script,
1852 off_chain_values: tagged_beef.off_chain_values.clone(),
1853 },
1854 AdmissionMode::WholeTx => OutputAdmittedByTopic::WholeTx {
1855 atomic_beef: subject_named_beef.clone(),
1856 output_index,
1857 topic: topic.clone(),
1858 off_chain_values: tagged_beef.off_chain_values.clone(),
1859 },
1860 };
1861
1862 if let Err(e) = hooked(bound, ls.output_admitted_by_topic(&payload)).await {
1863 error!("Error notifying lookup service: {e}");
1864 report.fault(topic, "lookup_service.output_admitted_by_topic", e);
1865 }
1866 }
1867 }
1868
1869 // An insert faulted: the transaction did not land, so NOTHING of
1870 // it stays (the lens fold of 2026-10-07, F1). The previous coins
1871 // are kept (#559: with the delete first, one faulted insert left
1872 // a non-retaining head chain with NO head) and the outputs that
1873 // were inserted are taken out again, the faulted one included (a
1874 // backend's insert can fault after its row landed). Left in,
1875 // they could be spent and deleted by a successor before the
1876 // replay, and the replay, which still finds the previous coin,
1877 // would insert them a second time: a spent head back among the
1878 // UTXOs for good.
1879 if insert_faulted {
1880 if let Some(bound) = bound {
1881 bound.renew();
1882 }
1883 self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
1884 .await;
1885 self.hold_unapplied(
1886 &txid,
1887 topic,
1888 &format!(
1889 "an insert faulted: {} previous coin(s) kept, nothing of it held",
1890 stale_coins.len()
1891 ),
1892 );
1893 continue;
1894 }
1895
1896 // ── Delete stale outputs recursively ──
1897 // AFTER the inserts, and once every one of them landed (bsv-low
1898 // #559 and the lens fold of 2026-10-07, F1). The reference
1899 // deletes first (`applyTopicStorageMutation`: `removeStaleOutputs`,
1900 // then `admitOutput`), catches a throw per topic and never
1901 // replays; here a fault is survived and replayed, so the order
1902 // is ours, an addition. The delete does NOT wait for a clean
1903 // report (c9921ee did, and one faulted mark or notification
1904 // after the insert then left the stale coin beside the new
1905 // output for good: two unspent heads): a fault of the mark, of
1906 // a notification, deletes as the reference does.
1907 //
1908 // Before a stale coin is deleted its transaction is recorded as
1909 // applied in the topic (the delta fold of 2026-10-07, H2; an
1910 // addition, one idempotent write per deleted coin). A held coin
1911 // proves the inserts of its transaction landed, but that
1912 // transaction may have no applied row (a fault after its
1913 // inserts withheld it, or its insert landed after its timeout),
1914 // and then its replay is judged again: a manager that admits it
1915 // with NO previous coin (the opener of a chain) had it inserted
1916 // a second time, unspent, beside the output that spent it, for
1917 // good. With the row its replay is a dupe. A record that faults
1918 // leaves its coin undeleted: that is a delete fault, below.
1919 let due_before_the_delete = bound.is_some_and(CallBound::is_due);
1920 let mut delete_faulted = false;
1921 let mut delete_started = false;
1922 for stale in &stale_coins {
1923 let spent_coin_tx = AppliedTransaction {
1924 txid: stale.txid.clone(),
1925 topic: topic.clone(),
1926 };
1927 if let Err(e) = stored(
1928 bound,
1929 self.storage.insert_applied_transaction(&spent_coin_tx),
1930 )
1931 .await
1932 {
1933 error!(
1934 "Error recording {} as applied before its coin is deleted from topic {topic}: {e}",
1935 stale.txid
1936 );
1937 report.fault(topic, "record_spent_coin_applied", e.to_string());
1938 delete_faulted = true;
1939 continue;
1940 }
1941 self.not_landed
1942 .borrow_mut()
1943 .remove(&(stale.txid.clone(), topic.clone()));
1944 match stored(
1945 bound,
1946 self.storage.find_output(
1947 &stale.txid,
1948 stale.output_index,
1949 Some(topic),
1950 None,
1951 false,
1952 ),
1953 )
1954 .await
1955 {
1956 Ok(Some(stale_output)) => {
1957 delete_started = true;
1958 if let Err(e) = self.delete_utxo_deep(&stale_output, bound).await {
1959 error!("Error deleting stale output for topic {topic}: {e}");
1960 report.fault(topic, "delete_utxo_deep", e.to_string());
1961 delete_faulted = true;
1962 }
1963 }
1964 Ok(None) => {}
1965 Err(e) => {
1966 error!("Error reading stale output for topic {topic}: {e}");
1967 report.fault(topic, "find_output", e.to_string());
1968 delete_faulted = true;
1969 }
1970 }
1971 }
1972
1973 // A fault here, and what it leaves. The store is put back as it
1974 // was found (the inserts undone, the replay does the whole
1975 // transaction) only when NO delete was started: the fault is of
1976 // H2's record or of the read before the delete, the call
1977 // answered, and EVERY stale coin is read back still held.
1978 //
1979 // Once a delete was STARTED nothing is undone, whether it
1980 // answered or not (the delta fold of 2026-10-07, H1, for a call
1981 // that did not answer; the second delta fold, M1, for one that
1982 // answered an error). A statement can land after its caller was
1983 // told it failed, as after its caller stopped waiting: the coin
1984 // is read back held now and is deleted a moment later, and the
1985 // undo then left the chain with no head, for good, for a manager
1986 // that needs the previous coin. So the outputs stay, the stale
1987 // coin stays (marked spent) until its delete lands or is done
1988 // again, and there is no applied row. Since H2 every order from
1989 // there converges. The replay first, the delete never landed:
1990 // the coin is found, the manager admits again, the inserts are
1991 // no-ops, the delete is done and the row written. The replay
1992 // first, the delete landed late: no previous coin, so a manager
1993 // that needs one admits nothing and the transaction is recorded
1994 // with its outputs held. A successor first: it recorded this
1995 // transaction as applied (H2) and the replay is a dupe; the
1996 // stale coin's spent row is then left behind, no UTXO.
1997 //
1998 // A call that did not ANSWER before any delete (the record, the
1999 // read) undoes nothing either: it is not known what it did. A
2000 // deadline that was already due BEFORE the first of these calls
2001 // started nothing here, so the coins are certainly held and the
2002 // store is put back.
2003 if delete_faulted {
2004 let unanswered = !due_before_the_delete && bound.is_some_and(CallBound::is_due);
2005 if let Some(bound) = bound {
2006 bound.renew();
2007 }
2008 let mut all_held = !unanswered && !delete_started;
2009 for stale in &stale_coins {
2010 if !all_held {
2011 break;
2012 }
2013 let read = stored(
2014 bound,
2015 self.storage.find_output(
2016 &stale.txid,
2017 stale.output_index,
2018 Some(topic),
2019 None,
2020 false,
2021 ),
2022 )
2023 .await;
2024 if !matches!(read, Ok(Some(_))) {
2025 all_held = false;
2026 break;
2027 }
2028 }
2029 if all_held {
2030 self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
2031 .await;
2032 }
2033 self.hold_unapplied(
2034 &txid,
2035 topic,
2036 if all_held {
2037 "no delete of a stale coin was started: the coins are held, its outputs taken out"
2038 } else if unanswered {
2039 "the delete of a stale coin did not answer: nothing undone, its outputs stay"
2040 } else if delete_started {
2041 "the delete of a stale coin faulted: nothing undone, its outputs stay"
2042 } else {
2043 "no delete was started and a stale coin was not read back held: nothing undone, its outputs stay"
2044 },
2045 );
2046 continue;
2047 }
2048
2049 // ── Update consumedBy on retained previous outputs ──
2050 for consumed_outpoint in &outputs_consumed {
2051 let consumed_output = match stored(
2052 bound,
2053 self.storage.find_output(
2054 &consumed_outpoint.txid,
2055 consumed_outpoint.output_index,
2056 Some(topic),
2057 None,
2058 false,
2059 ),
2060 )
2061 .await
2062 {
2063 Ok(Some(o)) => o,
2064 Ok(None) => continue,
2065 Err(e) => {
2066 error!("Error reading consumed output for topic {topic}: {e}");
2067 report.fault(topic, "find_output", e.to_string());
2068 continue;
2069 }
2070 };
2071 {
2072 let mut new_consumed_by = consumed_output.consumed_by.clone();
2073 for new_utxo in &new_utxos {
2074 if !new_consumed_by.iter().any(|c| {
2075 c.txid == new_utxo.txid && c.output_index == new_utxo.output_index
2076 }) {
2077 new_consumed_by.push(new_utxo.clone());
2078 }
2079 }
2080 if let Err(e) = stored(
2081 bound,
2082 self.storage.update_consumed_by(
2083 &consumed_outpoint.txid,
2084 consumed_outpoint.output_index,
2085 topic,
2086 &new_consumed_by,
2087 ),
2088 )
2089 .await
2090 {
2091 error!("Error updating consumedBy: {e}");
2092 report.fault(topic, "update_consumed_by", e.to_string());
2093 }
2094 }
2095 }
2096
2097 // Record applied transaction — ONLY when every write above
2098 // landed. A faulted topic stays unrecorded so the Phase-1 dedup
2099 // cannot turn its replay into a no-op (S2).
2100 if report.faults.len() > faults_before {
2101 self.hold_unapplied(
2102 &txid,
2103 topic,
2104 &format!(
2105 "{} mutation fault(s); a replay of these bytes re-applies",
2106 report.faults.len() - faults_before
2107 ),
2108 );
2109 continue;
2110 }
2111 let tx_record = AppliedTransaction {
2112 txid: txid.clone(),
2113 topic: topic.clone(),
2114 };
2115 match stored(bound, self.storage.insert_applied_transaction(&tx_record)).await {
2116 Ok(()) => {
2117 report.applied_topics.push(topic.clone());
2118 self.not_landed
2119 .borrow_mut()
2120 .remove(&(txid.clone(), topic.clone()));
2121 }
2122 Err(e) => {
2123 error!("Error inserting applied transaction for topic {topic}: {e}");
2124 report.fault(topic, "insert_applied_transaction", e.to_string());
2125 self.not_landed
2126 .borrow_mut()
2127 .insert((txid.clone(), topic.clone()));
2128 }
2129 }
2130 }
2131
2132 Ok((steak, report, blocked))
2133 }
2134
2135 /// The successor rule (bsv-low #559; lane E1D, bsv-low #575; and its
2136 /// lens fold of 2026-10-08), asked of every topic of a submit that found
2137 /// NO previous coin while the transaction spends something, before
2138 /// anything of the submit is written. Returns the topics that must wait,
2139 /// each with why: those are reported as a fault (`predecessor_not_landed`)
2140 /// and recorded nowhere, "not now". An addition to the reference, which
2141 /// records every non-failed topic.
2142 ///
2143 /// Such a topic was judged without the coin a predecessor has yet to
2144 /// leave. Recorded as applied, its replay was a dupe for good: a
2145 /// transaction that admits nothing ended the chain at the predecessor
2146 /// (#559), and one the manager admits WITHOUT its coin (the pf head
2147 /// manager over its own head state, E1D) was inserted, and the
2148 /// predecessor, landing later, stood unspent beside the tip.
2149 ///
2150 /// "Has not landed" is known two ways: this engine saw it fault
2151 /// (`not_landed`, one invocation, every door), or the store says so
2152 /// ([`Engine::unlanded_predecessor`], any invocation, never at a GASP
2153 /// finalize submit: its graph passed the anchor check, every parent
2154 /// inside the graph was submitted just before it and the sequence stops
2155 /// at the first that does not land; the second delta fold of 2026-10-07,
2156 /// M2). For a successor that admits something, both look only at the
2157 /// inputs the manager NAMES as its history (`identify_needed_inputs`
2158 /// over the subject-named BEEF; the lens fold, L1): a manager that
2159 /// names nothing (all 16 of this workspace) asks nothing there, as in
2160 /// the reference. A successor that admits NOTHING is asked of every
2161 /// input whatever its manager names (D17 (a)), so under such a manager
2162 /// it still waits for, or lands first, a carried body that spends a
2163 /// coin the topic holds: there the store differs from the reference's
2164 /// (the E1D delta fold, L3).
2165 ///
2166 /// The door lands a carried predecessor FIRST (the lens fold, M1; the
2167 /// GASP walk's rule, at the door): when the question names an unlanded
2168 /// predecessor whose body the BEEF carries, that body is submitted on
2169 /// its own to this topic ([`Engine::land_carried`]), under the submit's
2170 /// read allowance, and once it lands the topic is judged again
2171 /// ([`Engine::validate_topic`]) and asked again. The re-judgement's
2172 /// reads (the applied row, one coin per input) are charged to that
2173 /// allowance BEFORE the landing (the E1D delta fold, M1): a landing whose
2174 /// re-judgement would not fit is not made, and the topic waits. So one
2175 /// submit reads at most its own validation and writes plus
2176 /// [`PREDECESSOR_READS_PER_SUBMIT`], landings (on their clean path) and
2177 /// re-judgements included; a faulted landing's read-backs and undo, and
2178 /// a deep delete's walk of retained history, are not charged.
2179 /// Nothing is landed into a topic whose manager or a lookup
2180 /// service reads off-chain values (the delta fold, L1: the landing has
2181 /// none to give, and the predecessor's own submit would be a dupe): the
2182 /// topic waits for that submit. The first predecessor that does not land
2183 /// ends it: the topic waits. A predecessor whose body the BEEF does not
2184 /// carry is never landed here: its successor converges only when it
2185 /// lands (its own replay, a GASP peer, a resubmit).
2186 #[allow(clippy::too_many_arguments)]
2187 async fn successors_waiting(
2188 &self,
2189 tx: &Transaction,
2190 txid: &str,
2191 subject_named_beef: &[u8],
2192 validations: &mut [TopicValidation],
2193 steak: &mut Steak,
2194 off_chain_values: Option<&[u8]>,
2195 mode: SubmitMode,
2196 bound: Option<&CallBound>,
2197 finalize: bool,
2198 lands: bool,
2199 blocked: &mut HashMap<String, String>,
2200 question_reads: &mut usize,
2201 landed: &mut Vec<(String, String)>,
2202 landing: Landing,
2203 landed_breaches: &mut Vec<WalkCouldNotRun>,
2204 ) -> HashMap<String, String> {
2205 let mut waits = HashMap::new();
2206 let spent: Vec<(String, u32)> = tx
2207 .inputs
2208 .iter()
2209 .filter_map(|input| {
2210 let source = input.get_source_txid().ok()?;
2211 (!source.is_empty()).then_some((source, input.source_output_index))
2212 })
2213 .collect();
2214 if spent.is_empty() {
2215 return waits;
2216 }
2217 let found_no_coin = |v: &TopicValidation| {
2218 !v.is_dupe && !v.failed && v.read_fault.is_none() && v.previous_outputs.is_empty()
2219 };
2220 for v in validations.iter_mut() {
2221 if !found_no_coin(v) {
2222 continue;
2223 }
2224 let Some(manager) = self.managers.get(&v.topic) else {
2225 continue;
2226 };
2227 let topic = v.topic.clone();
2228 // The manager's word on the subject's history, asked at most
2229 // once per topic: up front for a successor that admits, by the
2230 // question when it needs it for one that does not.
2231 let mut subject_names: Option<HashSet<String>> = None;
2232 loop {
2233 let admits = !v.admittance.outputs_to_admit.is_empty();
2234 if admits && subject_names.is_none() {
2235 subject_names = Some(
2236 self.named_history(manager.as_ref(), subject_named_beef, bound)
2237 .await,
2238 );
2239 }
2240 let remembered = spent.iter().find(|(source, vout)| {
2241 (!admits
2242 || subject_names
2243 .as_ref()
2244 .is_some_and(|named| named.contains(&format!("{source}.{vout}"))))
2245 && self
2246 .not_landed
2247 .borrow()
2248 .contains(&(source.clone(), topic.clone()))
2249 });
2250 if let Some((predecessor, _)) = remembered {
2251 waits.insert(topic.clone(), format!("{predecessor} has not landed"));
2252 break;
2253 }
2254 if finalize {
2255 break;
2256 }
2257 let Some(not_now) = self
2258 .unlanded_predecessor(
2259 tx,
2260 txid,
2261 subject_named_beef,
2262 &topic,
2263 mode,
2264 admits,
2265 &mut subject_names,
2266 question_reads,
2267 bound,
2268 )
2269 .await
2270 else {
2271 break;
2272 };
2273 let Some(carried) = not_now.carried else {
2274 waits.insert(topic.clone(), not_now.why);
2275 break;
2276 };
2277 if !lands {
2278 blocked.insert(topic.clone(), carried);
2279 waits.insert(topic.clone(), not_now.why);
2280 break;
2281 }
2282 if !self.lands_without_values(&topic) {
2283 waits.insert(
2284 topic.clone(),
2285 format!(
2286 "{}; not landed first from the BEEF: the topic's manager or a lookup service reads off-chain values, and only the predecessor's own submit carries them",
2287 not_now.why
2288 ),
2289 );
2290 break;
2291 }
2292 // The re-judgement after the landing, charged before it.
2293 let rejudge = 1 + spent.len();
2294 if *question_reads + rejudge > PREDECESSOR_READS_PER_SUBMIT {
2295 waits.insert(
2296 topic.clone(),
2297 format!(
2298 "{}; its landing and the re-judgement after it do not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads",
2299 not_now.why
2300 ),
2301 );
2302 break;
2303 }
2304 *question_reads += rejudge;
2305 match self
2306 .land_carried(
2307 subject_named_beef,
2308 txid,
2309 &carried,
2310 &topic,
2311 mode,
2312 bound,
2313 question_reads,
2314 landed,
2315 landing,
2316 landed_breaches,
2317 )
2318 .await
2319 {
2320 Ok(()) => {
2321 *v = self
2322 .validate_topic(
2323 tx,
2324 txid,
2325 &topic,
2326 off_chain_values,
2327 mode,
2328 &TopicAdmittanceContext::default(),
2329 bound,
2330 )
2331 .await;
2332 steak.insert(topic.clone(), v.admittance.clone());
2333 if !found_no_coin(v) {
2334 break;
2335 }
2336 }
2337 Err(did_not_land) => {
2338 // No re-judgement follows: its reads were not made.
2339 *question_reads -= rejudge;
2340 waits.insert(
2341 topic.clone(),
2342 format!(
2343 "{}; submitted first from the BEEF, it did not land ({did_not_land})",
2344 not_now.why
2345 ),
2346 );
2347 break;
2348 }
2349 }
2350 }
2351 // A subject that already HOLDS an output in the topic landed
2352 // there before (the leftover of its own earlier submit whose
2353 // delete was started and faulted, D17 M1: its outputs held, no
2354 // applied row), and that admission was judged with its coin.
2355 // Its replay finishes it and does not wait (the E1D lens fold,
2356 // L4: a proven head spend that names a decoy no peer serves was
2357 // "not now" on every replay, three and a dead letter). One read,
2358 // only on the way to "not now", inside the allowance.
2359 if !finalize
2360 && waits.contains_key(&topic)
2361 && *question_reads < PREDECESSOR_READS_PER_SUBMIT
2362 {
2363 *question_reads += 1;
2364 if let Ok(outputs) = stored(
2365 bound,
2366 self.storage.find_outputs_for_transaction(txid, false),
2367 )
2368 .await
2369 {
2370 if outputs.iter().any(|o| o.topic == topic) {
2371 info!("topic {topic}: {txid} holds its outputs already; its replay finishes it");
2372 waits.remove(&topic);
2373 blocked.remove(&topic);
2374 }
2375 }
2376 }
2377 }
2378 waits
2379 }
2380
2381 /// Land an unlanded predecessor whose body the successor's BEEF carries
2382 /// (lane E1D's lens fold, M1): submit it on its own to `topic`, its
2383 /// atomic BEEF out of the successor's, no off-chain values (the caller
2384 /// lands nothing into a topic that reads them; the delta fold, L1),
2385 /// never a broadcast (`current-tx` is submitted as `historical-tx`: the
2386 /// successor's BEEF carries it to the network).
2387 ///
2388 /// Each body is WALKED once in this submit (the delta fold, L4): the
2389 /// successor's own SPV walk (`subject`, the same mode, tracker and
2390 /// switch) already passed over every body it reaches without crossing a
2391 /// proven one, so those are not walked again; a body reached only
2392 /// through a proven one (which the successor's walk does not descend)
2393 /// gets its own walk, trusting what this submit already walked, and so
2394 /// does nothing twice. A walk that refuses a body ends the landing.
2395 ///
2396 /// A predecessor whose own submit waits on a carried predecessor of ITS
2397 /// own goes on a stack under it: ancestors first, the walk's rule at
2398 /// the door. Its manager was asked only a DRY RUN then and nothing was
2399 /// written; once the one under it landed it is submitted again and
2400 /// admitted for real, once ([`Engine::submit_counted`]). Each submit's
2401 /// reads on its clean path (its applied row, one coin per input, and
2402 /// its writes' one read of each coin it spends) are charged to the
2403 /// submit's allowance before it starts, and its predecessor
2404 /// question shares that allowance, so the landings of one submit stop
2405 /// at [`PREDECESSOR_READS_PER_SUBMIT`] reads, however deep the chain.
2406 /// Each body that lands is pushed on `landed` (the report's
2407 /// `landed_predecessors`, for the caller's admission guard; the delta
2408 /// fold, L2). Each body is asked of the caller's landing guard first,
2409 /// once ([`Engine::set_landing_guard`]; the delta-2 fold, L2): a refusal
2410 /// ends the landing before that body is submitted.
2411 /// `Ok` once `predecessor` LANDED in `topic` (recorded there,
2412 /// now or before); otherwise why not, at the first that did not.
2413 #[allow(clippy::too_many_arguments)]
2414 async fn land_carried(
2415 &self,
2416 beef_bytes: &[u8],
2417 subject: &str,
2418 predecessor: &str,
2419 topic: &str,
2420 mode: SubmitMode,
2421 bound: Option<&CallBound>,
2422 question_reads: &mut usize,
2423 landed: &mut Vec<(String, String)>,
2424 landing: Landing,
2425 landed_breaches: &mut Vec<WalkCouldNotRun>,
2426 ) -> Result<(), String> {
2427 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
2428 .map_err(|e| format!("the BEEF could not be read again ({e})"))?;
2429 let mode = match mode {
2430 SubmitMode::CurrentTx => SubmitMode::HistoricalTx,
2431 other => other,
2432 };
2433 let walks = mode != SubmitMode::HistoricalTxNoSpv;
2434 // The trust set is what a walk that RAN covered (the E592 lens fold,
2435 // M1): a subject whose walk breached covered nothing, so each carried
2436 // predecessor is walked by its own submit (and its own breach is
2437 // reported, `landed_breaches`), never trusted by a breached walk.
2438 let mut walked: HashSet<String> = if walks && landing.subject_walk_ran {
2439 walk_cover(&beef, subject)
2440 } else {
2441 HashSet::new()
2442 };
2443 let mut stack = vec![predecessor.to_string()];
2444 let mut guarded: HashSet<String> = HashSet::new();
2445 while let Some(next) = stack.last().cloned() {
2446 let body = beef
2447 .find_atomic_transaction(&next)
2448 .ok_or_else(|| format!("{next}: its body is not in the BEEF"))?;
2449 // The caller's admission predicate, before anything of this body
2450 // is submitted (the delta-2 fold, L2).
2451 if let Some(guard) = &self.landing_guard {
2452 if guarded.insert(next.clone()) {
2453 bounded(bound, guard(&next, topic))
2454 .await
2455 .and_then(|answer| answer)
2456 .map_err(|why| format!("{next}: refused by the landing guard ({why})"))?;
2457 }
2458 }
2459 // Its validation (the applied row, one coin per input) and its
2460 // writes' one read of each coin it spends.
2461 let cost = 1 + 2 * body.inputs.len();
2462 if *question_reads + cost > PREDECESSOR_READS_PER_SUBMIT {
2463 return Err(format!(
2464 "{next}: its submit does not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2465 ));
2466 }
2467 *question_reads += cost;
2468 let atomic = body
2469 .to_atomic_beef(true)
2470 .map_err(|e| format!("{next}: its BEEF could not be built ({e})"))?;
2471 let tagged = TaggedBEEF::new(atomic, vec![topic.to_string()]);
2472 let (_, report, blocked) = match self
2473 .submit_counted(
2474 &tagged,
2475 mode,
2476 bound,
2477 false,
2478 Some(&walked),
2479 question_reads,
2480 landing.policy,
2481 )
2482 .await
2483 {
2484 Ok(counted) => counted,
2485 // Its walk could not run and nothing stands in for it: it does
2486 // not land now, and the successor waits (counted, L3).
2487 Err(EngineError::WalkCouldNotRun(stop)) => {
2488 let why = format!("{next}: {}", EngineError::WalkCouldNotRun(stop.clone()));
2489 landed_breaches.push(stop);
2490 return Err(why);
2491 }
2492 Err(e) => return Err(format!("{next}: {e}")),
2493 };
2494 // Its walk passed, so did that of every body it reaches; a walk
2495 // that went on past a breach (`NetworkAccepted`) passed nothing.
2496 if let Some(stop) = &report.walk_could_not_run {
2497 landed_breaches.push(stop.clone());
2498 } else if walks && !walked.contains(&next) {
2499 walked.extend(walk_cover(&beef, &next));
2500 }
2501 let landed_now = report.applied_topics.iter().any(|t| t == topic);
2502 if report.is_durable()
2503 && (landed_now || report.deduped_topics.iter().any(|t| t == topic))
2504 {
2505 info!("topic {topic}: {next} landed first from its successor's BEEF");
2506 if landed_now {
2507 landed.push((next.clone(), topic.to_string()));
2508 }
2509 stack.pop();
2510 continue;
2511 }
2512 match blocked.get(topic) {
2513 Some(under) if !stack.contains(under) => stack.push(under.clone()),
2514 _ if report.is_durable() => return Err(format!("{next}: the manager failed it")),
2515 _ => return Err(format!("{next}: {}", report.summary())),
2516 }
2517 }
2518 Ok(())
2519 }
2520
2521 /// A topic of a submit that is NOT recorded as applied: logged, and
2522 /// remembered for the successor rule (`not_landed`).
2523 fn hold_unapplied(&self, txid: &str, topic: &str, why: &str) {
2524 warn!("topic {topic}: {txid}: {why}; applied_transactions NOT recorded (bsv-low #559)");
2525 self.not_landed
2526 .borrow_mut()
2527 .insert((txid.to_string(), topic.to_string()));
2528 }
2529
2530 /// Take out again the outputs a faulted submit inserted (bsv-low #559,
2531 /// the lens fold of 2026-10-07, F1), so that nothing of a transaction
2532 /// that did not land is held. Only a row nobody has touched since is
2533 /// deleted: unspent and consumed by nothing. A plain delete, not the
2534 /// deep one: the row's retained inputs were not pointed at it yet. A
2535 /// fault here is reported (`undo_insert_output`) and the row stays: the
2536 /// limit of an undo made of separate calls.
2537 async fn undo_inserts(
2538 &self,
2539 txid: &str,
2540 topic: &str,
2541 output_indices: &[u32],
2542 bound: Option<&CallBound>,
2543 report: &mut MutationReport,
2544 ) {
2545 for &output_index in output_indices {
2546 let row = stored(
2547 bound,
2548 self.storage
2549 .find_output(txid, output_index, Some(topic), None, false),
2550 )
2551 .await;
2552 let undone = match row {
2553 Ok(Some(row)) if !row.spent && row.consumed_by.is_empty() => {
2554 stored(bound, self.storage.delete_output(txid, output_index, topic)).await
2555 }
2556 Ok(_) => Ok(()),
2557 Err(e) => Err(e),
2558 };
2559 if let Err(e) = undone {
2560 error!("Error taking {txid}.{output_index} out of topic {topic} again: {e}");
2561 report.fault(topic, "undo_insert_output", e.to_string());
2562 }
2563 }
2564 }
2565
2566 /// The store's own answer to "has a predecessor of `tx` not landed?"
2567 /// (bsv-low #559, the lens fold of 2026-10-07, F3; lane E1D, bsv-low
2568 /// #575, and its lens fold of 2026-10-08), for a transaction that found
2569 /// no previous coin. The engine's memory of a faulted submit
2570 /// (`not_landed`) dies with the invocation; this does not. A transaction
2571 /// HAS LANDED in `topic` when it has an applied row there or an output
2572 /// held there; anything else is not known to have landed, and it is a
2573 /// PREDECESSOR of `tx` when one of these says so:
2574 ///
2575 /// - its body is in the BEEF and it spends a coin the topic HOLDS (or,
2576 /// one step up, a coin of another such transaction): a spend of this
2577 /// topic the store has not taken yet (#559);
2578 /// - its body is in the BEEF, it spends no coin the topic holds, the
2579 /// manager NAMES the outpoint the walk spends from it as overlay
2580 /// history (`identify_needed_inputs` over the BEEF of the transaction
2581 /// that spends it, `tx` or a body the walk reached), and the manager,
2582 /// asked of that body in a dry run with no coins
2583 /// (`TopicAdmittanceContext::DRY_RUN`, the submit's mode, no off-chain
2584 /// values), would admit that named output: an OPENER that has not
2585 /// landed (E1D, the cure D17 named). Only a NAMED output is dry-run
2586 /// (the lens fold, H1): with every unheld body dry-run, a manager that
2587 /// admits on output shape (all 16 of this workspace) made a spend of a
2588 /// shape-admissible output this node never held (a revocation of an
2589 /// ad it never saw; a stranger's own few-sat SHIP output) "not now" on
2590 /// every presentation, three replays and a dead letter each, where the
2591 /// reference records it. A manager `Err` in the dry run is "not now"
2592 /// (the manager's contract at the anchor replay, D15), except its
2593 /// typed refusal `NoAdmissibleOutputs`, which is "not admitted";
2594 /// - its body is NOT in the BEEF (a PROVEN successor carries none) and
2595 /// the manager names the outpoint `tx` spends from it as overlay
2596 /// history (D13's word; an `Err` there names nothing, as in the walk).
2597 ///
2598 /// The manager is asked over `beef_bytes`, the SUBJECT-NAMED BEEF of the
2599 /// submit (BRC-95 atomic, the body the lookup services get; the lens
2600 /// fold, M2): over the submitted bytes a manager that parses
2601 /// `from_beef(_, None)` took the wire-LAST transaction of a non-atomic
2602 /// BEEF, named an ancestor's inputs, and the question asked nothing.
2603 ///
2604 /// `admits` says which inputs of `tx` start the walk. A transaction that
2605 /// admits nothing (D17's class) starts from every input. One that admits
2606 /// WITHOUT its coin (E1D) starts only from the inputs the manager names:
2607 /// a manager that admits a successor with no coin has said the coin does
2608 /// not decide the admission, so only its own word makes an input a
2609 /// predecessor. Every manager of this workspace names nothing: an
2610 /// admitting successor is then asked nothing, as in the reference, and
2611 /// so is a spend of an unheld output nobody names (the lens fold, H1);
2612 /// but a successor that admits nothing still finds case (a), a carried
2613 /// body that spends a coin the topic holds, and waits for it or has it
2614 /// landed first, where the reference records the successor and never
2615 /// admits that body (the E1D delta fold, L3). A named input whose body
2616 /// the BEEF carries is still walked and dry-run, so a named decoy the
2617 /// manager would not admit is no predecessor.
2618 ///
2619 /// The answer carries the predecessor when its body is in the BEEF (the
2620 /// first two cases): the door lands it first ([`Engine::successors_waiting`]).
2621 ///
2622 /// The limits, stated. It cannot tell a faulted predecessor from one
2623 /// nobody submitted yet: a successor is "not now" in both until the
2624 /// predecessor lands (the door lands a carried one itself). A named
2625 /// input whose body is absent and which never lands (a decoy no one
2626 /// submits, beside a real predecessor that landed and holds no coin)
2627 /// keeps its successor "not now" at the door; the GASP walk prunes such
2628 /// an input instead (D14). Deeper than the successor's own inputs, an
2629 /// absent body ends the walk as before.
2630 ///
2631 /// "Landed" needs a clean answer (the delta fold of 2026-10-07, M1 and
2632 /// M2): a read that faults, or the question running out of its reads
2633 /// (the 16 of a topic or the 256 of the submit), answers "not now" too,
2634 /// naming the transaction it could not settle. Recording the successor
2635 /// there made its replay a dupe and stopped the chain behind it for
2636 /// good; a retried no-op is the lesser cost.
2637 ///
2638 /// What the bound counts (the second delta fold of 2026-10-07, M2): the
2639 /// reads spent on a body that is NEITHER proven in the BEEF NOR found
2640 /// landed, [`PREDECESSOR_READS`] of them per topic. A body the BEEF
2641 /// proves, and one whose applied row or held output answers "landed", is
2642 /// read and costs nothing against that bound: counted, they made a
2643 /// transaction that admits nothing over 17 landed parents, over six
2644 /// proven parents the topic never saw, or over one proven parent with 15
2645 /// inputs "not now" on every submit, with no unlanded predecessor
2646 /// anywhere. A landed body still needs ROOM under it: each read of an
2647 /// unproven body is tested before it is made, so with 15 counted reads
2648 /// spent one that landed by a held output (no applied row) is refused at
2649 /// its second read, and with 16 spent any unproven body at its first.
2650 ///
2651 /// "Proven" is the BEEF's word here: a body that carries a merkle path
2652 /// (a bump index) is counted as proven for the read bound ONLY. No proof
2653 /// is checked at this question; the SPV walk of the submit and the GASP
2654 /// anchor check are where proofs are checked, and under
2655 /// `historical-tx-no-spv` or the scripts-only walk nothing checked this
2656 /// one, so the 16 are the caller's to switch off. Nothing is recorded on
2657 /// that word (a proven body is walked like any other), and the allowance
2658 /// below does not ask it.
2659 ///
2660 /// The hard allowance (the third delta fold of 2026-10-07, M1):
2661 /// `question_reads`, owned by the submit, counts EVERY read of the
2662 /// question, those of proven and landed bodies included, over every
2663 /// topic of the submit, and stops it at
2664 /// [`PREDECESSOR_READS_PER_SUBMIT`]; the 16 stay inside it. A later
2665 /// topic's question starts from what the earlier ones left.
2666 ///
2667 /// The costs, stated. A transaction that admits nothing, found no coin
2668 /// and carries more UNPROVEN, UNLANDED bodies than 16 reads settle (five
2669 /// single-input ancestors, fewer with more inputs), or a BEEF whose
2670 /// question needs more than 256 reads over the submit's topics (a
2671 /// landed body costs one read by its applied row or two by a held
2672 /// output, a proven, unlanded one two and one per input), is never
2673 /// recorded, where the reference records it: each submit of it is a
2674 /// fault and a replay. The manager CPU it adds: one names call per
2675 /// spender a dry run needs (the subject's, and one per walked body with
2676 /// an unheld source), and at most one dry run per body the reads
2677 /// reached; each candidate is built out of the BEEF parsed once (the
2678 /// lens fold, L2).
2679 ///
2680 /// Returns why the submit must wait, for the report.
2681 #[allow(clippy::too_many_arguments)]
2682 async fn unlanded_predecessor(
2683 &self,
2684 tx: &Transaction,
2685 txid: &str,
2686 beef_bytes: &[u8],
2687 topic: &str,
2688 mode: SubmitMode,
2689 admits: bool,
2690 subject_names: &mut Option<HashSet<String>>,
2691 question_reads: &mut usize,
2692 bound: Option<&CallBound>,
2693 ) -> Option<NotNow> {
2694 let manager = self.managers.get(topic)?;
2695 let wait = |why: String| Some(NotNow { why, carried: None });
2696 // The outpoints `tx` spends, as `txid.vout`.
2697 let starts: Vec<(String, u32)> = tx
2698 .inputs
2699 .iter()
2700 .filter_map(|input| {
2701 let source = input.get_source_txid().ok()?;
2702 (!source.is_empty()).then_some((source, input.source_output_index))
2703 })
2704 .collect();
2705 // The outputs of each candidate that the walk spends, and who spends
2706 // each: a dry run asks only of an output its spender names.
2707 let mut spent_of: HashMap<String, HashSet<u32>> = HashMap::new();
2708 let mut spenders: HashMap<String, Vec<(String, u32)>> = HashMap::new();
2709 let mut ask: Vec<String> = Vec::new();
2710 for (source, vout) in &starts {
2711 if admits
2712 && subject_names
2713 .as_ref()
2714 .is_some_and(|named| !named.contains(&format!("{source}.{vout}")))
2715 {
2716 continue;
2717 }
2718 spent_of.entry(source.clone()).or_default().insert(*vout);
2719 spenders
2720 .entry(source.clone())
2721 .or_default()
2722 .push((txid.to_string(), *vout));
2723 ask.push(source.clone());
2724 }
2725 // Nothing to ask (a transaction that admits and names nothing, every
2726 // manager of this workspace): the BEEF is not even parsed.
2727 if ask.is_empty() {
2728 return None;
2729 }
2730 let direct: HashSet<String> = ask.iter().cloned().collect();
2731 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS).ok()?;
2732 // Each body the BEEF carries, and whether the BEEF says it is
2733 // proven (a bump index: its word, no proof is checked here).
2734 let bodies: HashMap<String, (&Transaction, bool)> = beef
2735 .txs
2736 .iter()
2737 .filter_map(|btx| {
2738 btx.tx()
2739 .map(|body| (btx.txid(), (body, btx.bump_index().is_some())))
2740 })
2741 .collect();
2742 // The manager's names over each walked body other than `tx`.
2743 let mut names_over: HashMap<String, HashSet<String>> = HashMap::new();
2744 // The reads spent on bodies neither proven nor found landed.
2745 let mut reads = 0usize;
2746 let mut asked: HashSet<String> = HashSet::new();
2747 while let Some(candidate) = ask.pop() {
2748 if candidate.is_empty() || !asked.insert(candidate.clone()) {
2749 continue;
2750 }
2751 let spent_outputs = spent_of.get(&candidate).cloned().unwrap_or_default();
2752 // An absent body is asked of the store only when it is one of
2753 // `tx`'s own inputs and the manager names it (E1D).
2754 let body = bodies.get(&candidate).copied();
2755 if body.is_none() {
2756 if !direct.contains(&candidate) {
2757 continue;
2758 }
2759 if subject_names.is_none() {
2760 *subject_names = Some(
2761 self.named_history(manager.as_ref(), beef_bytes, bound)
2762 .await,
2763 );
2764 }
2765 let is_named = subject_names.as_ref().is_some_and(|named| {
2766 spent_outputs
2767 .iter()
2768 .any(|vout| named.contains(&format!("{candidate}.{vout}")))
2769 });
2770 if !is_named {
2771 continue;
2772 }
2773 }
2774 let proven = body.is_some_and(|(_, proven)| proven);
2775 // This candidate's own reads: they join `reads` once it is
2776 // found not landed, and never if the BEEF proves it.
2777 let mut spent = 0usize;
2778 let out_of = |spent: usize| !proven && reads + spent >= PREDECESSOR_READS;
2779 let out_of_reads = || {
2780 wait(format!(
2781 "{candidate} is not known to have landed: the question ran out of its {PREDECESSOR_READS} reads"
2782 ))
2783 };
2784 // The submit's allowance, over every read: proven or landed,
2785 // this topic or an earlier one.
2786 let out_of_allowance = || {
2787 wait(format!(
2788 "{candidate} is not known to have landed: the question ran out of the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2789 ))
2790 };
2791 let unread = |e: StorageError| {
2792 wait(format!(
2793 "{candidate} is not known to have landed: the store could not say ({e})"
2794 ))
2795 };
2796 let carried = |why: String| {
2797 Some(NotNow {
2798 why,
2799 carried: Some(candidate.clone()),
2800 })
2801 };
2802 let record = AppliedTransaction {
2803 txid: candidate.clone(),
2804 topic: topic.to_string(),
2805 };
2806 // An applied row, or an output held, ends the question for this
2807 // transaction: it landed.
2808 if out_of(spent) {
2809 return out_of_reads();
2810 }
2811 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2812 return out_of_allowance();
2813 }
2814 spent += 1;
2815 *question_reads += 1;
2816 match stored(bound, self.storage.does_applied_transaction_exist(&record)).await {
2817 Ok(false) => {}
2818 Ok(true) => continue,
2819 Err(e) => return unread(e),
2820 }
2821 if out_of(spent) {
2822 return out_of_reads();
2823 }
2824 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2825 return out_of_allowance();
2826 }
2827 spent += 1;
2828 *question_reads += 1;
2829 match stored(
2830 bound,
2831 self.storage.find_outputs_for_transaction(&candidate, false),
2832 )
2833 .await
2834 {
2835 Ok(outputs) if outputs.iter().all(|o| o.topic != topic) => {}
2836 Ok(_) => continue,
2837 Err(e) => return unread(e),
2838 }
2839 // Not landed. With no body to walk, the manager's word is the
2840 // whole answer (E1D).
2841 let Some((body, _)) = body else {
2842 return wait(format!(
2843 "{candidate} has not landed (the manager names it as history; the BEEF does not carry it)"
2844 ));
2845 };
2846 for input in &body.inputs {
2847 let source = input.get_source_txid().unwrap_or_default();
2848 if source.is_empty() {
2849 continue;
2850 }
2851 if out_of(spent) {
2852 return out_of_reads();
2853 }
2854 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2855 return out_of_allowance();
2856 }
2857 spent += 1;
2858 *question_reads += 1;
2859 let coin = stored(
2860 bound,
2861 self.storage.find_output(
2862 &source,
2863 input.source_output_index,
2864 Some(topic),
2865 None,
2866 false,
2867 ),
2868 )
2869 .await;
2870 match coin {
2871 Ok(Some(_)) => return carried(format!("{candidate} has not landed")),
2872 // Not held: its own transaction may be the one that has
2873 // not landed.
2874 Ok(None) => {
2875 spent_of
2876 .entry(source.clone())
2877 .or_default()
2878 .insert(input.source_output_index);
2879 spenders
2880 .entry(source.clone())
2881 .or_default()
2882 .push((candidate.clone(), input.source_output_index));
2883 ask.push(source);
2884 }
2885 Err(e) => return unread(e),
2886 }
2887 }
2888 // It spends no coin the topic holds. Does a spender NAME an
2889 // output the walk spends of it (the lens fold, H1)? A names call
2890 // per spender, no store read.
2891 let mut named_outputs: HashSet<u32> = HashSet::new();
2892 for (spender, vout) in spenders.get(&candidate).cloned().unwrap_or_default() {
2893 let names = if spender == txid {
2894 if subject_names.is_none() {
2895 *subject_names = Some(
2896 self.named_history(manager.as_ref(), beef_bytes, bound)
2897 .await,
2898 );
2899 }
2900 subject_names.as_ref()
2901 } else {
2902 if !names_over.contains_key(&spender) {
2903 let names = match beef
2904 .find_atomic_transaction(&spender)
2905 .and_then(|spender| spender.to_atomic_beef(true).ok())
2906 {
2907 Some(spender_beef) => {
2908 self.named_history(manager.as_ref(), &spender_beef, bound)
2909 .await
2910 }
2911 None => HashSet::new(),
2912 };
2913 names_over.insert(spender.clone(), names);
2914 }
2915 names_over.get(&spender)
2916 };
2917 if names.is_some_and(|named| named.contains(&format!("{candidate}.{vout}"))) {
2918 named_outputs.insert(vout);
2919 }
2920 }
2921 if !named_outputs.is_empty() {
2922 // Would the manager admit a named output of it with no coins
2923 // (E1D, the opener)? A dry run over the candidate built out
2924 // of the BEEF parsed above (its proof and, unproven, its
2925 // sources linked): no store read, nothing counted.
2926 let judged = beef
2927 .find_atomic_transaction(&candidate)
2928 .unwrap_or_else(|| body.clone());
2929 let verdict = bounded(
2930 bound,
2931 manager.identify_admissible_outputs(
2932 &judged,
2933 &[],
2934 None,
2935 mode,
2936 &TopicAdmittanceContext::DRY_RUN,
2937 ),
2938 )
2939 .await;
2940 match verdict {
2941 Ok(Ok(admittance)) => {
2942 if admittance
2943 .outputs_to_admit
2944 .iter()
2945 .any(|vout| named_outputs.contains(vout))
2946 {
2947 return carried(format!(
2948 "{candidate} has not landed (the manager names the output spent from it and would admit it with no coin: an opener)"
2949 ));
2950 }
2951 }
2952 Ok(Err(TopicManagerError::NoAdmissibleOutputs(_))) => {}
2953 Ok(Err(e)) => {
2954 return wait(format!(
2955 "{candidate} is not known to have landed: the manager could not judge it ({e})"
2956 ))
2957 }
2958 Err(no_answer) => {
2959 return wait(format!(
2960 "{candidate} is not known to have landed: the manager did not answer ({no_answer})"
2961 ))
2962 }
2963 }
2964 }
2965 if !proven {
2966 reads += spent;
2967 }
2968 }
2969 None
2970 }
2971
2972 /// The outpoints (`txid.vout`) the topic manager NAMES as overlay
2973 /// history of the transaction `beef_bytes` names (`identify_needed_inputs`,
2974 /// D13's word; an atomic BEEF, so a manager that parses
2975 /// `from_beef(_, None)` reads that transaction). An `Err` or no answer
2976 /// names nothing, as the GASP walk treats it (logged).
2977 async fn named_history(
2978 &self,
2979 manager: &dyn TopicManager,
2980 beef_bytes: &[u8],
2981 bound: Option<&CallBound>,
2982 ) -> HashSet<String> {
2983 match bounded(bound, manager.identify_needed_inputs(beef_bytes, None)).await {
2984 Ok(Ok(named)) => named.iter().map(Outpoint::to_graph_id).collect(),
2985 Ok(Err(e)) => {
2986 warn!("the predecessor question: the manager could not name its inputs: {e}");
2987 HashSet::new()
2988 }
2989 Err(no_answer) => {
2990 warn!("the predecessor question: the manager did not name its inputs: {no_answer}");
2991 HashSet::new()
2992 }
2993 }
2994 }
2995
2996 /// The reference's `tx.verify(chainTracker)` on submit: every unproven
2997 /// transaction from the subject down has every input's script executed
2998 /// against its source output and obeys the value rule; a proven one is
2999 /// not descended, and its BUMP's root is checked against the chain
3000 /// tracker (computed and accepted with none).
3001 ///
3002 /// Since bsv-low #592 (the owner's ruling 3a of 2026-10-10) the scripts
3003 /// are walked by the DOOR's stream walk ([`crate::script_door`]) under
3004 /// `budget`, the engine's [`DoorBudget`]: one walker, the same estimates
3005 /// charged from the bytes before anything is parsed or run. Before it this
3006 /// was a walk of its own over a hydrated `Beef` with no memory charge and
3007 /// no interpreter limit: 219.7 MB natively on a 1.9 MB push-only unproven
3008 /// parent, an isolate kill on Workers, met by every `historical-tx` replay
3009 /// of the queue. The roots are then asked for the proven transactions the
3010 /// walk reached, in its order, once per BUMP.
3011 ///
3012 /// `Ok(Some(_))` is a walk that COULD NOT RUN within the budget, never a
3013 /// refusal (below, at the match). The interpreter's refusal and a
3014 /// structural fault are what they were: `ScriptVerificationFailed`, and
3015 /// `SpvError` in the same words.
3016 ///
3017 /// The tracker is an ARGUMENT, not `self`'s: the GASP anchor check
3018 /// ([`verify_spv_like_the_reference`]) runs this same walk from
3019 /// `OverlayGASPStorage`, which borrows the engine's tracker.
3020 ///
3021 /// Why not bsv-rs `Transaction::verify`: it walks `source_transaction`
3022 /// links that `from_beef` builds by CLONING, once per input. Two inputs
3023 /// sourcing the same unproven parent (a covenant head output plus that
3024 /// spend's own change, the shape of every second head spend of a wallet)
3025 /// leave the second clone bare ("Input 0 has no source transaction"), and
3026 /// re-linking every clone materializes the ancestry EXPONENTIALLY along a
3027 /// diamond chain: a 12-deep unmined chain of head spends took a Worker past
3028 /// its memory on beta (zanaadu, 2026-09-08). The stream walk reads each
3029 /// transaction once, in place, keyed by txid.
3030 async fn verify_beef_linear(
3031 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3032 beef_bytes: &[u8],
3033 subject_txid: &str,
3034 trusted: &HashSet<String>,
3035 budget: DoorBudget,
3036 ) -> Result<Option<WalkCouldNotRun>, EngineError> {
3037 // A structural fault is the reference walk's `SpvError`.
3038 let spv =
3039 |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3040
3041 // THE SCRIPTS (bsv-low #592): the door's own stream walk, under the
3042 // engine's budget. One walker (Rule 10): the door and the submit run
3043 // the same function, the same estimates and the same interpreter
3044 // limit; the submit adds the roots below.
3045 let mut trace = crate::script_door::WalkTrace::default();
3046 match crate::script_door::walk_trusting(
3047 beef_bytes,
3048 subject_txid,
3049 budget,
3050 trusted,
3051 &mut trace,
3052 ) {
3053 Ok(_) => {}
3054 // The interpreter's verdict, the reference's refusal: unchanged.
3055 Err(refused @ EngineError::ScriptVerificationFailed { .. }) => return Err(refused),
3056 // A structural fault (a source missing, the value rule, ...):
3057 // the reference walk's `SpvError`, in the same words as before.
3058 Err(EngineError::ScriptWalkInconclusive { reason, .. }) => return Err(spv(reason)),
3059 // The budget, not the transaction: the walk COULD NOT RUN. Not a
3060 // refusal: the bytes are a BEEF the walk was not given the room
3061 // to judge, which says nothing about whether its scripts are
3062 // valid (refusing would refuse a valid BEEF for its size, the
3063 // owner's posture). Not an admission either: the caller's
3064 // [`WalkBreachPolicy`] decides, and only the network's word on
3065 // record lets the submit go on (the E592 lens fold, H1). The roots
3066 // the walk reached are checked first, below.
3067 Err(EngineError::ScriptWalkOverBudget {
3068 at_txid,
3069 subject_judged,
3070 limb,
3071 what,
3072 }) => {
3073 let limb = match limb {
3074 DoorLimb::Memory => WalkLimb::OverMemory,
3075 DoorLimb::Work if trace.interpreter_tripped => WalkLimb::InterpreterMemory,
3076 DoorLimb::Work => WalkLimb::OverWork,
3077 };
3078 // THE ROOTS OF A BREACHED WALK (the E592 lens fold, H1): every
3079 // proven transaction it reached, and every proven source an
3080 // input read, whatever the scripts' verdict. A bad root is
3081 // the `SpvError` it is on a walk that ran: a breach never
3082 // skips a proof the walk relied on. Their cost is a tracker
3083 // question per BUMP, which the budget does not bound and
3084 // does not need to (the clean walk asks the same).
3085 let reached: Vec<String> = trace
3086 .proven
3087 .iter()
3088 .chain(&trace.proven_sources)
3089 .cloned()
3090 .collect();
3091 Self::check_reached_roots(chain_tracker, beef_bytes, &reached).await?;
3092 return Ok(Some(WalkCouldNotRun {
3093 subject_txid: subject_txid.to_string(),
3094 at_txid,
3095 subject_judged,
3096 limb,
3097 what,
3098 }));
3099 }
3100 Err(other) => return Err(other),
3101 }
3102
3103 // THE ROOTS: every proven transaction the walk reached, in the order
3104 // it reached them, its BUMP's root computed and asked of the tracker
3105 // once per BUMP (NL-6e). With no tracker the roots are computed and
3106 // accepted (`'scripts only'`). The walk trusted them as it went; the
3107 // reference checks them in the same walk, so a body with BOTH a bad
3108 // script and a bad root now names the script.
3109 Self::check_reached_roots(chain_tracker, beef_bytes, &trace.proven).await?;
3110 Ok(None)
3111 }
3112
3113 /// The roots of `reached` (display txids, in order; a repeat or a second
3114 /// transaction of one BUMP asks nothing more), each BUMP's root computed
3115 /// and asked of the tracker once. None reached: the body is not parsed.
3116 async fn check_reached_roots(
3117 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3118 beef_bytes: &[u8],
3119 reached: &[String],
3120 ) -> Result<(), EngineError> {
3121 let spv =
3122 |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3123 if reached.is_empty() {
3124 return Ok(());
3125 }
3126 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
3127 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3128 let proven_in = proven_index(&beef);
3129 let mut checked: HashSet<usize> = HashSet::new();
3130 for txid in reached {
3131 let Some(&at) = proven_in.get(txid.as_str()) else {
3132 return Err(spv(format!("transaction {txid} is not in the BEEF")));
3133 };
3134 if !checked.insert(at) {
3135 continue; // its BUMP's root is the chain's, asked once
3136 }
3137 let mp = &beef.bumps[at];
3138 let root = mp
3139 .compute_root(Some(txid))
3140 .map_err(|e| spv(format!("invalid merkle path for transaction {txid}: {e}")))?;
3141 if let Some(tracker) = chain_tracker {
3142 match tracker
3143 .is_valid_root_for_height(&root, mp.block_height)
3144 .await
3145 {
3146 Ok(true) => {}
3147 Ok(false) => {
3148 return Err(EngineError::SpvError(format!(
3149 "Invalid merkle path for transaction {txid}: root {root} is not valid for block height {}",
3150 mp.block_height
3151 )));
3152 }
3153 Err(e) => {
3154 return Err(EngineError::SpvError(format!(
3155 "Chain tracker error at height {}: {e}",
3156 mp.block_height
3157 )));
3158 }
3159 }
3160 }
3161 }
3162 Ok(())
3163 }
3164
3165 /// The pre-2026-09-08 SPV block, kept as the
3166 /// [`Engine::set_script_verification`] `false` escape hatch: BEEF
3167 /// structural validity plus every root against the chain tracker, and
3168 /// NOTHING when no tracker is configured. It never executes a script.
3169 ///
3170 /// The structure is the streaming reader's
3171 /// ([`beef_limits::structure_roots`], bsv-rs 0.4.2): one element in hand,
3172 /// each BUMP walked once, linear in its leaves (NL-6e). Until NL-6e it
3173 /// was `Beef::verify_valid`, whose root check at bsv-rs 0.4.0 walked a
3174 /// BUMP once per leaf: one BUMP of 8,192 leaves gave no answer in 120 s
3175 /// (bsv-stack-lean NL-8 W1). The reader reads the wire's order, where
3176 /// `verify_valid` sorted first: an input that names a later transaction
3177 /// is refused here.
3178 async fn verify_spv_structurally(
3179 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3180 beef_bytes: &[u8],
3181 ) -> Result<(), EngineError> {
3182 let Some(chain_tracker) = chain_tracker else {
3183 return Ok(());
3184 };
3185 let roots = beef_limits::structure_roots(beef_bytes)
3186 .map_err(|r| EngineError::SpvError(format!("BEEF structure refused: {r}")))?;
3187 let mut asked: HashSet<(u64, &str)> = HashSet::new();
3188 for (height, root) in &roots {
3189 if !asked.insert((*height, root.as_str())) {
3190 continue;
3191 }
3192 let Ok(height) = u32::try_from(*height) else {
3193 return Err(EngineError::SpvError(format!(
3194 "Merkle root {root} invalid for block height {height}"
3195 )));
3196 };
3197 match chain_tracker.is_valid_root_for_height(root, height).await {
3198 Ok(true) => {}
3199 Ok(false) => {
3200 return Err(EngineError::SpvError(format!(
3201 "Merkle root {root} invalid for block height {height}"
3202 )));
3203 }
3204 Err(e) => {
3205 return Err(EngineError::SpvError(format!(
3206 "Chain tracker error at height {height}: {e}"
3207 )));
3208 }
3209 }
3210 }
3211 Ok(())
3212 }
3213
3214 /// Phase 1 of ONE topic: the dedup read, the previous coins, the
3215 /// manager's judgement. Read-only. [`Engine::run_validation`] asks it of
3216 /// every topic; the door asks it again of a topic whose predecessor it
3217 /// landed first from the BEEF (lane E1D's lens fold, M1).
3218 #[allow(clippy::too_many_arguments)]
3219 async fn validate_topic(
3220 &self,
3221 tx: &Transaction,
3222 txid: &str,
3223 topic: &str,
3224 off_chain_values: Option<&[u8]>,
3225 mode: SubmitMode,
3226 context: &TopicAdmittanceContext,
3227 bound: Option<&CallBound>,
3228 ) -> TopicValidation {
3229 let tx_record = AppliedTransaction {
3230 txid: txid.to_string(),
3231 topic: topic.to_string(),
3232 };
3233 // A dedup read that FAULTS is the topic's read fault (the delta
3234 // fold of 2026-10-07, H3): nothing is judged and nothing is
3235 // written for the topic, the fault rides the report and the
3236 // replay asks again. The reference fails the topic there
3237 // (`Engine.ts` `submit`: the throw lands in the per-topic catch,
3238 // `failedTopics.add`). Read as "not a dupe", as it was, a
3239 // re-presented transaction the manager admits with no previous
3240 // coin was inserted a second time, unspent, beside the output
3241 // that had spent it, under a durable report.
3242 let is_dupe = match stored(
3243 bound,
3244 self.storage.does_applied_transaction_exist(&tx_record),
3245 )
3246 .await
3247 {
3248 Ok(is_dupe) => is_dupe,
3249 Err(e) => {
3250 error!("Error reading the applied row of {txid} for topic {topic}: {e}");
3251 return TopicValidation {
3252 topic: topic.to_string(),
3253 is_dupe: false,
3254 previous_coins: vec![],
3255 previous_outputs: vec![],
3256 admittance: AdmittanceInstructions::default(),
3257 failed: false,
3258 read_fault: Some(("does_applied_transaction_exist", e.to_string())),
3259 };
3260 }
3261 };
3262
3263 if is_dupe {
3264 return TopicValidation {
3265 topic: topic.to_string(),
3266 is_dupe: true,
3267 previous_coins: vec![],
3268 previous_outputs: vec![],
3269 admittance: AdmittanceInstructions::default(),
3270 failed: false,
3271 read_fault: None,
3272 };
3273 }
3274
3275 let mut previous_coins: Vec<u32> = Vec::new();
3276 let mut previous_outputs: Vec<Output> = Vec::new();
3277 let mut read_fault: Option<(&'static str, String)> = None;
3278
3279 for (input_idx, input) in tx.inputs.iter().enumerate() {
3280 let source_txid = input.get_source_txid().unwrap_or_default();
3281 if source_txid.is_empty() {
3282 continue;
3283 }
3284 match stored(
3285 bound,
3286 self.storage.find_output(
3287 &source_txid,
3288 input.source_output_index,
3289 Some(topic),
3290 None,
3291 false,
3292 ),
3293 )
3294 .await
3295 {
3296 Ok(Some(prev_output)) => {
3297 previous_coins.push(input_idx as u32);
3298 previous_outputs.push(prev_output);
3299 }
3300 Ok(None) => {}
3301 Err(e) => {
3302 // A faulted read is NOT "no previous coin": the
3303 // spend may consume an admitted output we simply
3304 // could not read. Validation proceeds (the topic
3305 // manager decides on what it can see) but the fault
3306 // rides the report and blocks the applied record.
3307 error!(
3308 "Error reading previous output {source_txid}:{} for topic {topic}: {e}",
3309 input.source_output_index
3310 );
3311 if read_fault.is_none() {
3312 read_fault = Some(("find_output", e.to_string()));
3313 }
3314 }
3315 }
3316 }
3317
3318 let judged = self
3319 .judge(
3320 tx,
3321 topic,
3322 &previous_coins,
3323 off_chain_values,
3324 mode,
3325 context,
3326 bound,
3327 )
3328 .await;
3329 match judged {
3330 Ok(admittance) => TopicValidation {
3331 topic: topic.to_string(),
3332 is_dupe: false,
3333 previous_coins,
3334 previous_outputs,
3335 admittance,
3336 failed: false,
3337 read_fault: read_fault.clone(),
3338 },
3339 Err(e) => {
3340 error!("Error validating topic {topic} during submit: {e}");
3341 TopicValidation {
3342 topic: topic.to_string(),
3343 is_dupe: false,
3344 previous_coins: vec![],
3345 previous_outputs: vec![],
3346 admittance: AdmittanceInstructions::default(),
3347 failed: true,
3348 read_fault,
3349 }
3350 }
3351 }
3352 }
3353
3354 /// The topic manager's judgement of `tx` over the previous coins found
3355 /// (input indices), as [`Engine::validate_topic`] asks it; a carried
3356 /// predecessor's submit asks it again for real once its topic does not
3357 /// wait (the E1D delta fold, L4).
3358 #[allow(clippy::too_many_arguments)]
3359 async fn judge(
3360 &self,
3361 tx: &Transaction,
3362 topic: &str,
3363 previous_coins: &[u32],
3364 off_chain_values: Option<&[u8]>,
3365 mode: SubmitMode,
3366 context: &TopicAdmittanceContext,
3367 bound: Option<&CallBound>,
3368 ) -> Result<AdmittanceInstructions, TopicManagerError> {
3369 let manager = &self.managers[topic];
3370 let previous_coin_bytes = previous_coins
3371 .iter()
3372 .flat_map(|i| i.to_le_bytes())
3373 .collect::<Vec<u8>>();
3374 // Under a finalize submit's bound a manager that does not answer
3375 // is a manager that failed: nothing is written for the topic.
3376 bounded(
3377 bound,
3378 manager.identify_admissible_outputs(
3379 // The ONE engine parse of the submitted BEEF, shared by
3380 // every topic on this submit (bsv-low #289 — managers
3381 // used to re-parse the same bytes independently).
3382 tx,
3383 &previous_coin_bytes,
3384 off_chain_values,
3385 mode,
3386 // `dry_run` is `false` on a submit, `true` on a
3387 // validate-only call.
3388 context,
3389 ),
3390 )
3391 .await
3392 .map_err(TopicManagerError::Other)
3393 .and_then(|judged| judged)
3394 }
3395
3396 /// Whether the door may land a carried predecessor into `topic` (the
3397 /// E1D delta fold, L1): neither its manager nor any lookup service
3398 /// reads off-chain values there, which the landing does not have.
3399 fn lands_without_values(&self, topic: &str) -> bool {
3400 self.managers
3401 .get(topic)
3402 .is_some_and(|manager| !manager.reads_off_chain_values())
3403 && self
3404 .lookup_services
3405 .values()
3406 .all(|ls| !ls.reads_off_chain_values(topic))
3407 }
3408
3409 /// Run Phase 1 (topic validation) and Phase 2 (broadcast) without mutating storage.
3410 ///
3411 /// Returns (validations, steak, parsed_tx, txid) so the caller can either
3412 /// stop (validate-only) or proceed with Phase 3 (mutations).
3413 async fn run_validation(
3414 &self,
3415 tagged_beef: &TaggedBEEF,
3416 mode: SubmitMode,
3417 context: &TopicAdmittanceContext,
3418 bound: Option<&CallBound>,
3419 walked: Option<&HashSet<String>>,
3420 policy: WalkBreachPolicy,
3421 ) -> Result<Validated, EngineError> {
3422 // Validate all topics are supported
3423 for topic in &tagged_beef.topics {
3424 if !self.managers.contains_key(topic) {
3425 return Err(EngineError::UnsupportedTopic(topic.clone()));
3426 }
3427 }
3428
3429 // Parse the SUBJECT from the BEEF — order-independent (`subject.rs`,
3430 // loop-2 hardening 2026-09-05): `from_beef(_, None)` takes the last
3431 // tx in wire order, which an SDK-sorted INCOMPLETE ancestry fills
3432 // with a fully-sourced ancestor; the engine then judged a hop, admitted
3433 // nothing and the pot never entered the index while its JOIN mined.
3434 let subject = {
3435 let mut b =
3436 beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
3437 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3438 crate::subject::subject_txid_of(&mut b)
3439 };
3440 let tx = beef_limits::transaction_from_beef(
3441 &tagged_beef.beef,
3442 subject.as_deref(),
3443 &beef_limits::ENGINE_BEEF_LIMITS,
3444 )
3445 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3446 let txid = tx.id();
3447
3448 // SPV verification, skipped ONLY for HistoricalTxNoSpv, exactly the
3449 // reference's `if (mode !== 'historical-tx-no-spv') tx.verify(...)`.
3450 // That mode exists for GASP `finalizeGraph`, whose graphs
3451 // `validateGraphAnchor` already verified, and since bsv-low #551 that
3452 // is TRUE here: `OverlayGASPStorage::validate_graph_anchor` runs this
3453 // same check ([`verify_spv_like_the_reference`], the same tracker and
3454 // the same switch) over the ROOT node's BEEF and replays the graph
3455 // through the topic manager before a single BEEF of it reaches this
3456 // door. Before #551 the anchor check was a no-op and this skip
3457 // admitted a peer's graph on structure alone.
3458 //
3459 // A predecessor the door lands first (`walked`, the E1D delta fold,
3460 // L4) is not walked again when this submit's walks already passed
3461 // over it, and its walk trusts what they passed over.
3462 //
3463 // The walk runs under the engine's budget (bsv-low #592,
3464 // [`Engine::set_walk_budget`]): one that could not run within it is
3465 // NOT a refusal (see `verify_beef_linear`), and NOT an admission
3466 // unless the caller holds the network's accept of these bytes (the
3467 // E592 lens fold, H1, [`WalkBreachPolicy`]): "not now" before any
3468 // manager is asked, or, under `NetworkAccepted`, this submit goes on
3469 // as under `HistoricalTxNoSpv` and the report says so.
3470 let mut could_not_run = None;
3471 if mode != SubmitMode::HistoricalTxNoSpv {
3472 match walked {
3473 Some(walked) if walked.contains(&txid) => {}
3474 trusted => {
3475 could_not_run = verify_spv_trusting(
3476 self.chain_tracker.as_deref(),
3477 self.verify_scripts,
3478 &tagged_beef.beef,
3479 &txid,
3480 trusted.unwrap_or(&HashSet::new()),
3481 self.walk_budget,
3482 )
3483 .await?;
3484 }
3485 }
3486 }
3487 if let Some(stop) = could_not_run.take() {
3488 match policy {
3489 WalkBreachPolicy::NotNow => {
3490 warn!(
3491 "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): not now, nothing judged or written; present it proven (bsv-low #592, E592 fold)",
3492 stop.subject_txid,
3493 stop.limb.as_str(),
3494 stop.at_txid,
3495 stop.subject_judged,
3496 stop.what
3497 );
3498 return Err(EngineError::WalkCouldNotRun(stop));
3499 }
3500 WalkBreachPolicy::NetworkAccepted => {
3501 warn!(
3502 "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): the network accepted these bytes, the submit goes on as historical-tx-no-spv (bsv-low #592)",
3503 stop.subject_txid,
3504 stop.limb.as_str(),
3505 stop.at_txid,
3506 stop.subject_judged,
3507 stop.what
3508 );
3509 could_not_run = Some(stop);
3510 }
3511 }
3512 }
3513
3514 let mut steak = Steak::new();
3515
3516 // =============================================================
3517 // PHASE 1: VALIDATE (read-only)
3518 // =============================================================
3519 let mut validations = Vec::new();
3520
3521 for topic in &tagged_beef.topics {
3522 validations.push(
3523 self.validate_topic(
3524 &tx,
3525 &txid,
3526 topic,
3527 tagged_beef.off_chain_values.as_deref(),
3528 mode,
3529 context,
3530 bound,
3531 )
3532 .await,
3533 );
3534 }
3535
3536 // Build preliminary STEAK
3537 for v in &validations {
3538 steak.insert(v.topic.clone(), v.admittance.clone());
3539 }
3540
3541 // =============================================================
3542 // PHASE 2: BROADCAST / SHIP propagation (before mutations)
3543 // =============================================================
3544 if mode == SubmitMode::CurrentTx {
3545 // ── ARC network broadcast ──────────────────────────────────
3546 // Broadcast to miners via ARC, matching the TS Engine pattern.
3547 // Skip if the tx already has a merkle path (already mined).
3548 //
3549 // INCIDENT D1-CALLBACK-FLOOD 2026-09-01: also skip when EVERY
3550 // topic classified the tx as a dupe (already applied) — the
3551 // exact `!v.is_dupe` filter the SHIP arm below has always had,
3552 // ten lines away. Without it, every RE-PRESENT of an
3553 // already-applied tx (cron ad-sync, peer crawl, a client
3554 // retrying a default-mode /submit) re-POSTed to Arcade and
3555 // re-registered the status callback, and registrations
3556 // accumulate per POST on Arcade's side — the flood's fuel. A
3557 // genuinely new tx (any topic non-dupe) broadcasts exactly as
3558 // before.
3559 let all_topics_dupe = !validations.is_empty() && validations.iter().all(|v| v.is_dupe);
3560 if let Some(ref arc) = self.arc_broadcaster {
3561 if all_topics_dupe {
3562 info!(
3563 "Skipping ARC broadcast — tx already applied to every topic (re-present)"
3564 );
3565 } else if tx.merkle_path.is_none() {
3566 let raw_tx_hex = tx.to_hex();
3567 match arc.broadcast(&raw_tx_hex).await {
3568 Ok(arc_txid) => {
3569 info!("ARC broadcast succeeded: txid={arc_txid}");
3570 }
3571 Err(e) => {
3572 error!("ARC broadcast failed (non-fatal): {e}");
3573 }
3574 }
3575 } else {
3576 info!("Skipping ARC broadcast — tx already has merkle proof");
3577 }
3578 }
3579
3580 // ── SHIP peer propagation ──────────────────────────────────
3581 if let Some(ref broadcaster) = self.broadcaster {
3582 let relevant_topics: Vec<String> = validations
3583 .iter()
3584 .filter(|v| {
3585 !v.is_dupe && !v.failed && !v.admittance.outputs_to_admit.is_empty()
3586 })
3587 .map(|v| v.topic.clone())
3588 .collect();
3589
3590 if !relevant_topics.is_empty() {
3591 if let Some(ship_ls) = self.lookup_services.get("ls_ship") {
3592 for topic in &relevant_topics {
3593 let question = LookupQuestion::new(
3594 "ls_ship",
3595 serde_json::json!({ "topics": [topic] }),
3596 );
3597 match ship_ls.lookup(&question).await {
3598 Ok(LookupResult::OutputList(refs)) => {
3599 for reference in &refs {
3600 if let Ok(Some(output)) = self
3601 .storage
3602 .find_output(
3603 &reference.txid,
3604 reference.output_index,
3605 None,
3606 None,
3607 false,
3608 )
3609 .await
3610 {
3611 if let Some(domain) =
3612 parse_ship_domain_from_script(&output.output_script)
3613 {
3614 if let Some(ref our_url) = self.config.hosting_url {
3615 if domain.trim_end_matches('/')
3616 == our_url.trim_end_matches('/')
3617 {
3618 continue;
3619 }
3620 }
3621 if let Err(e) = broadcaster
3622 .broadcast_to_host(&domain, tagged_beef)
3623 .await
3624 {
3625 error!(
3626 "SHIP propagation to {domain} failed for topic {topic}: {e}"
3627 );
3628 }
3629 }
3630 }
3631 }
3632 }
3633 Ok(LookupResult::Answer(_)) => {
3634 // SHIP only ever returns OutputList; an Answer here means
3635 // a misconfigured replacement service. Skip rather than
3636 // attempt to extract refs from a freeform/formula payload.
3637 error!(
3638 "SHIP lookup for topic {topic} returned a pre-formed \
3639 LookupAnswer; expected OutputList. Skipping fanout."
3640 );
3641 }
3642 Err(e) => {
3643 error!("SHIP lookup for topic {topic} failed: {e}");
3644 }
3645 }
3646 }
3647 }
3648 }
3649 }
3650 }
3651
3652 Ok((validations, steak, tx, txid, could_not_run))
3653 }
3654
3655 // ========================================================================
3656 // Lookup
3657 // ========================================================================
3658
3659 /// Answer a lookup query.
3660 ///
3661 /// Delegates to the appropriate LookupService, then hydrates results with BEEF.
3662 ///
3663 /// When `history_selector` is provided, each output is hydrated with its
3664 /// ancestor spend chain (via `get_utxo_history`) before building the response.
3665 /// This matches the TS Engine behavior when a history selector is configured.
3666 pub async fn lookup(
3667 &self,
3668 question: &LookupQuestion,
3669 history_selector: Option<HistorySelector>,
3670 ) -> Result<LookupAnswer, EngineError> {
3671 Ok(self.lookup_with_txids(question, history_selector).await?.0)
3672 }
3673
3674 /// Same as [`Engine::lookup`], additionally returning each hydrated
3675 /// output's txid, aligned index-for-index with the `OutputList` items
3676 /// (empty for pre-formed `Answer`s).
3677 ///
3678 /// The txid IS the storage primary key the row was just fetched by —
3679 /// handing it to the caller lets the aggregated wire serializer write it
3680 /// directly instead of re-deriving it with a full BEEF parse plus a
3681 /// double-SHA256 per output (bsv-low #289).
3682 pub async fn lookup_with_txids(
3683 &self,
3684 question: &LookupQuestion,
3685 history_selector: Option<HistorySelector>,
3686 ) -> Result<(LookupAnswer, Vec<String>), EngineError> {
3687 let service = self
3688 .lookup_services
3689 .get(&question.service)
3690 .ok_or_else(|| EngineError::LookupServiceNotFound(question.service.clone()))?;
3691
3692 // Preserve WHOSE fault it was. A lookup service reports a malformed
3693 // query as `InvalidQuery` / `Unsupported`; stringifying both into
3694 // `LookupFailed` (as this did) threw that away and reported every
3695 // caller mistake as a 500.
3696 let result = service.lookup(question).await.map_err(|e| match e {
3697 LookupServiceError::InvalidQuery(m) | LookupServiceError::Unsupported(m) => {
3698 EngineError::InvalidQuery(m)
3699 }
3700 other => EngineError::LookupFailed(other.to_string()),
3701 })?;
3702
3703 // Two paths per LookupResult:
3704 // - OutputList(refs): the LS yields outpoints; we hydrate each with
3705 // BEEF (and optional ancestor chain via history_selector) and
3706 // assemble LookupAnswer::OutputList.
3707 // - Answer(answer): the LS already produced the full answer
3708 // (Freeform/Formula); pass through verbatim. The Engine does NOT
3709 // apply history-selector hydration to a pre-formed Answer — the
3710 // LS is presumed to have embedded whatever ancestry it wants.
3711 let refs = match result {
3712 LookupResult::OutputList(refs) => refs,
3713 LookupResult::Answer(answer) => return Ok((answer, Vec::new())),
3714 };
3715
3716 // ONE batched hydration query for the whole result set instead of a
3717 // D1 round-trip per row (the #289 N+1). Order is preserved: the
3718 // batch contract returns outputs in input-outpoint order, skipping
3719 // outpoints with no stored row. A storage error is a REAL error now
3720 // (it used to be swallowed per-row into a silently-empty list — the
3721 // "vanishing table" failure mode): an uncertain read must never
3722 // masquerade as a confidently-empty lobby.
3723 let outpoints: Vec<Outpoint> = refs
3724 .iter()
3725 .map(|r| Outpoint::new(&r.txid, r.output_index))
3726 .collect();
3727 let found = self
3728 .storage
3729 .find_outputs_by_outpoints(&outpoints, true)
3730 .await
3731 .map_err(|e| EngineError::StorageError(e.to_string()))?;
3732
3733 let mut outputs = Vec::new();
3734 let mut txids = Vec::new();
3735 for output in found {
3736 // If history selector provided, hydrate ancestor chain
3737 let final_output = if history_selector.is_some() {
3738 match self
3739 .get_utxo_history(&output, history_selector.clone())
3740 .await
3741 {
3742 Ok(Some(hydrated)) => hydrated,
3743 _ => output,
3744 }
3745 } else {
3746 output
3747 };
3748
3749 // Skip an output whose BEEF is missing OR empty: a lookup item
3750 // without decodable BEEF is useless to the client (it can only
3751 // drop it), and returning an empty-BEEF row is what made a fresh
3752 // LOW table show up in a query yet be undecodable/invisible to
3753 // opponents (the "vanishing table" — 2026-07-11). Only ever
3754 // return fully-hydrated, decodable outputs.
3755 if let Some(beef) = final_output.beef {
3756 if !beef.is_empty() {
3757 txids.push(final_output.txid.clone());
3758 outputs.push(OutputListItem {
3759 beef,
3760 output_index: final_output.output_index,
3761 context: None,
3762 });
3763 }
3764 }
3765 }
3766
3767 Ok((LookupAnswer::OutputList { outputs }, txids))
3768 }
3769
3770 // ========================================================================
3771 // Metadata
3772 // ========================================================================
3773
3774 // ========================================================================
3775 // UTXO History
3776 // ========================================================================
3777
3778 /// Traverse and return the history of a UTXO.
3779 ///
3780 /// If no `history_selector` is provided, returns the output as-is.
3781 /// If a depth selector is provided, includes up to N levels of ancestor
3782 /// transactions by embedding them as source_transactions in the BEEF.
3783 pub async fn get_utxo_history(
3784 &self,
3785 output: &Output,
3786 history_selector: Option<HistorySelector>,
3787 ) -> Result<Option<Output>, EngineError> {
3788 let Some(selector) = history_selector else {
3789 return Ok(Some(output.clone()));
3790 };
3791
3792 // Verify BEEF exists before attempting hydration
3793 if output.beef.is_none() {
3794 return Err(EngineError::Other(
3795 "Output must have associated transaction BEEF!".into(),
3796 ));
3797 }
3798
3799 match self.hydrate_utxo_history(output, &selector, 0).await {
3800 Ok(Some(hydrated_output)) => Ok(Some(hydrated_output)),
3801 Ok(None) => Ok(None),
3802 Err(e) => Err(EngineError::Other(format!(
3803 "Error retrieving UTXO history: {e}"
3804 ))),
3805 }
3806 }
3807
3808 /// Recursive UTXO history hydration.
3809 fn hydrate_utxo_history<'a>(
3810 &'a self,
3811 output: &'a Output,
3812 selector: &'a HistorySelector,
3813 current_depth: u32,
3814 ) -> std::pin::Pin<
3815 Box<dyn std::future::Future<Output = Result<Option<Output>, EngineError>> + 'a>,
3816 > {
3817 Box::pin(async move {
3818 let beef_data = output.beef.as_ref().ok_or_else(|| {
3819 EngineError::Other("Output must have associated transaction BEEF!".into())
3820 })?;
3821
3822 // Check if we should traverse at this depth
3823 let should_traverse = match selector {
3824 HistorySelector::Depth(max_depth) => current_depth <= *max_depth,
3825 };
3826
3827 if !should_traverse {
3828 return Ok(None);
3829 }
3830
3831 // Start from the STORED BEEF, not from a re-parsed subject.
3832 //
3833 // #4: this used to be `Transaction::from_beef(beef_data, None)` followed
3834 // by `tx.to_beef(true)`, which keeps only the subject transaction and
3835 // therefore DESTROYS the stored BUMPs. Measured on prod before the fix:
3836 // `ls_low` byGameId `73de6e48…` returned 1,172 B / 1 tx / 1 bump (PROVEN)
3837 // with no header, and 676 B / 1 tx / 0 bumps with `x-history-depth: 3` —
3838 // a caller asking for MORE provenance got back a BEEF no wallet can
3839 // verify, silently, behind a 200.
3840 //
3841 // Accumulating into the stored BEEF fixes both halves: its own bumps
3842 // survive, and so does any ancestry that arrived inside the submitted
3843 // bytes but was never admitted as an output of its own (a foreign
3844 // parent), which the `outputs_consumed` walk below cannot see.
3845 let mut acc = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3846 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3847
3848 // For each consumed output, recursively hydrate and embed as source transaction
3849 for consumed in &output.outputs_consumed {
3850 if let Ok(Some(child_output)) = self
3851 .storage
3852 .find_output(&consumed.txid, consumed.output_index, None, None, true)
3853 .await
3854 {
3855 if let Ok(Some(hydrated_child)) = self
3856 .hydrate_utxo_history(&child_output, selector, current_depth + 1)
3857 .await
3858 {
3859 // MERGE the hydrated ancestor in, rather than embedding it
3860 // as a `source_transaction` on a re-parsed subject: a merge
3861 // carries the child's own BUMPs across, which is the whole
3862 // point of asking for history. An unparseable child is
3863 // skipped — never allowed to poison the accumulator.
3864 if let Some(ref child_beef) = hydrated_child.beef {
3865 if let Ok(child) = Beef::from_binary(child_beef) {
3866 acc.merge_beef(&child);
3867 }
3868 }
3869 }
3870 }
3871 }
3872
3873 Ok(Some(Output {
3874 beef: Some(acc.to_binary()),
3875 ..output.clone()
3876 }))
3877 })
3878 }
3879
3880 // ========================================================================
3881 // Metadata
3882 // ========================================================================
3883
3884 pub async fn list_topic_managers(&self) -> HashMap<String, ServiceMetadata> {
3885 let mut result = HashMap::new();
3886 for (name, manager) in &self.managers {
3887 let meta = manager.get_metadata().await;
3888 result.insert(name.clone(), meta);
3889 }
3890 result
3891 }
3892
3893 /// List all registered lookup services with their metadata.
3894 pub async fn list_lookup_service_providers(&self) -> HashMap<String, ServiceMetadata> {
3895 let mut result = HashMap::new();
3896 for (name, service) in &self.lookup_services {
3897 let meta = service.get_metadata().await;
3898 result.insert(name.clone(), meta);
3899 }
3900 result
3901 }
3902
3903 /// Get documentation for a specific topic manager.
3904 pub async fn get_documentation_for_topic_manager(&self, name: &str) -> String {
3905 match self.managers.get(name) {
3906 Some(manager) => manager.get_documentation().await,
3907 None => "No documentation found!".to_string(),
3908 }
3909 }
3910
3911 /// Get documentation for a specific lookup service.
3912 pub async fn get_documentation_for_lookup_service(&self, name: &str) -> String {
3913 match self.lookup_services.get(name) {
3914 Some(service) => service.get_documentation().await,
3915 None => "No documentation found!".to_string(),
3916 }
3917 }
3918
3919 // ========================================================================
3920 // GASP endpoints
3921 // ========================================================================
3922
3923 /// Respond to a GASP initial sync request.
3924 ///
3925 /// Returns UTXOs for the given topic since the requested score.
3926 ///
3927 /// The caller-supplied `limit` is CLAMPED to
3928 /// [`Engine::SYNC_RESPONSE_MAX_LIMIT`] (and defaulted when absent):
3929 /// `/requestSyncResponse` is a PUBLIC route, and an absent limit used to
3930 /// mean an unbounded scan of the outputs table (bsv-low #291). Bounding
3931 /// is lossless for sync correctness because the initiator
3932 /// ([`crate::gasp::GASPSync`]) pages for as long as its `since` score
3933 /// cursor advances — a truncated page is fetched by its NEXT request
3934 /// within the same run (gate finding M1: pagination must not demand a
3935 /// full page, which this clamp can make impossible).
3936 pub async fn provide_foreign_sync_response(
3937 &self,
3938 request: &GASPInitialRequest,
3939 topic: &str,
3940 ) -> Result<GASPInitialResponse, EngineError> {
3941 let limit = Self::clamp_sync_limit(request.limit);
3942 let outputs = self
3943 .storage
3944 .find_utxos_for_topic(topic, Some(request.since as f64), Some(limit), false)
3945 .await
3946 .map_err(|e| EngineError::StorageError(e.to_string()))?;
3947
3948 Ok(GASPInitialResponse {
3949 utxo_list: outputs
3950 .iter()
3951 .map(|o| GASPOutput {
3952 txid: o.txid.clone(),
3953 output_index: o.output_index,
3954 score: o.score.unwrap_or(0.0),
3955 })
3956 .collect(),
3957 since: request.since,
3958 })
3959 }
3960
3961 /// Provide a GASPNode for a specific transaction within a graph.
3962 ///
3963 /// Searches the BEEF tree of the root output (identified by graphID) for
3964 /// the requested txid. If not found in the BEEF tree, recurses through
3965 /// outputsConsumed in storage.
3966 pub async fn provide_foreign_gasp_node(
3967 &self,
3968 graph_id: &str,
3969 txid: &str,
3970 output_index: u32,
3971 ) -> Result<GASPNode, EngineError> {
3972 let root_outpoint = Outpoint::from_graph_id(graph_id)
3973 .ok_or_else(|| EngineError::Other(format!("Invalid graphID: {graph_id}")))?;
3974
3975 let root_output = self
3976 .storage
3977 .find_output(
3978 &root_outpoint.txid,
3979 root_outpoint.output_index,
3980 None,
3981 None,
3982 true,
3983 )
3984 .await
3985 .map_err(|e| EngineError::StorageError(e.to_string()))?
3986 .ok_or(EngineError::NodeNotFound)?;
3987
3988 self.hydrate_gasp_node(&root_output, graph_id, txid, output_index)
3989 .await
3990 }
3991
3992 /// Recursively search a BEEF tree for a specific txid and return a GASPNode.
3993 fn hydrate_gasp_node<'a>(
3994 &'a self,
3995 output: &'a Output,
3996 graph_id: &'a str,
3997 txid: &'a str,
3998 output_index: u32,
3999 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GASPNode, EngineError>> + 'a>>
4000 {
4001 Box::pin(async move {
4002 let beef_data = output.beef.as_ref().ok_or(EngineError::NodeNotFound)?;
4003
4004 // The stored body's SUBJECT by the same order-independent rule
4005 // (`subject.rs`) — a plain stored body's last tx can be an ancestor.
4006 let root = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
4007 .ok()
4008 .and_then(|mut b| crate::subject::subject_txid_of(&mut b));
4009 let root_tx = beef_limits::transaction_from_beef(
4010 beef_data,
4011 root.as_deref(),
4012 &beef_limits::STORED_BEEF_LIMITS,
4013 )
4014 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
4015
4016 // Search the transaction tree for the requested txid
4017 if let Some(node) = Self::search_tx_tree(&root_tx, graph_id, txid, output_index) {
4018 return Ok(node);
4019 }
4020
4021 // Fallback: try parsing BEEF with the target txid directly
4022 // (some BEEF structures have the tx in the list but not linked as source_transaction)
4023 if let Some(node) = Self::search_beef_for_txid(beef_data, graph_id, txid, output_index)
4024 {
4025 return Ok(node);
4026 }
4027
4028 // Not found in BEEF tree — recurse through outputsConsumed in storage
4029 for consumed in &output.outputs_consumed {
4030 if let Ok(Some(consumed_output)) = self
4031 .storage
4032 .find_output(&consumed.txid, consumed.output_index, None, None, true)
4033 .await
4034 {
4035 if let Ok(node) = self
4036 .hydrate_gasp_node(&consumed_output, graph_id, txid, output_index)
4037 .await
4038 {
4039 return Ok(node);
4040 }
4041 }
4042 }
4043
4044 // The graph root is known but neither its BEEF nor anything it
4045 // consumed holds the requested transaction. That is a definite
4046 // "not held", the same answer as an unknown root: the reference
4047 // throws `Unable to find output associated with your request!`
4048 // here and its route answers 400. A syncing peer reads the 400 as
4049 // `GASPError::NodeNotFound` and prunes a decoy input on it (the D8
4050 // rule, `GASPSync::process_incoming_node`); a 500 would read as a
4051 // fault of the moment and strand the chain behind the decoy.
4052 Err(EngineError::NodeNotFound)
4053 })
4054 }
4055
4056 /// Search a Transaction tree (via sourceTransaction links) for a specific txid.
4057 fn search_tx_tree(
4058 tx: &Transaction,
4059 graph_id: &str,
4060 target_txid: &str,
4061 output_index: u32,
4062 ) -> Option<GASPNode> {
4063 let current_txid = tx.id();
4064
4065 if current_txid == target_txid {
4066 let mut node = GASPNode {
4067 graph_id: graph_id.to_string(),
4068 raw_tx: tx.to_hex(),
4069 output_index,
4070 proof: None,
4071 tx_metadata: None,
4072 output_metadata: None,
4073 inputs: None,
4074 };
4075 if let Some(ref merkle_path) = tx.merkle_path {
4076 node.proof = Some(merkle_path.to_hex());
4077 }
4078 return Some(node);
4079 }
4080
4081 // Recurse into inputs via source_transaction
4082 for input in &tx.inputs {
4083 if let Some(ref source_tx) = input.source_transaction {
4084 if let Some(node) =
4085 Self::search_tx_tree(source_tx, graph_id, target_txid, output_index)
4086 {
4087 return Some(node);
4088 }
4089 }
4090 }
4091
4092 None
4093 }
4094
4095 /// Alternative search: try parsing the BEEF with the target txid directly.
4096 /// Some BEEF structures may not link source_transaction on inputs but
4097 /// still contain the transaction in the BEEF's transaction list.
4098 fn search_beef_for_txid(
4099 beef_data: &[u8],
4100 graph_id: &str,
4101 target_txid: &str,
4102 output_index: u32,
4103 ) -> Option<GASPNode> {
4104 // Try parsing BEEF with the specific txid
4105 if let Ok(tx) = beef_limits::transaction_from_beef(
4106 beef_data,
4107 Some(target_txid),
4108 &beef_limits::STORED_BEEF_LIMITS,
4109 ) {
4110 if tx.id() == target_txid {
4111 let mut node = GASPNode {
4112 graph_id: graph_id.to_string(),
4113 raw_tx: tx.to_hex(),
4114 output_index,
4115 proof: None,
4116 tx_metadata: None,
4117 output_metadata: None,
4118 inputs: None,
4119 };
4120 if let Some(ref merkle_path) = tx.merkle_path {
4121 node.proof = Some(merkle_path.to_hex());
4122 }
4123 return Some(node);
4124 }
4125 }
4126 None
4127 }
4128
4129 // ========================================================================
4130 // Merkle proof handling
4131 // ========================================================================
4132
4133 /// Handle a new merkle proof for a transaction.
4134 ///
4135 /// When a transaction gets mined, ARC calls back with the merkle proof.
4136 /// This method:
4137 /// 1. Finds all outputs for the txid in storage
4138 /// 2. Parses the BEEF, updates the merkle path in the transaction tree
4139 /// 3. Serializes updated BEEF back to storage
4140 /// 4. Recursively updates the consumedBy chain (outputs that spent this one)
4141 /// 5. Updates blockHeight on the outputs
4142 pub async fn handle_new_merkle_proof(
4143 &self,
4144 txid: &str,
4145 proof_hex: &str,
4146 block_height: Option<u32>,
4147 ) -> Result<(), EngineError> {
4148 let outputs = self
4149 .storage
4150 .find_outputs_for_transaction(txid, true)
4151 .await
4152 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4153
4154 if outputs.is_empty() {
4155 return Err(EngineError::Other(
4156 "Could not find matching transaction outputs for proof ingest!".into(),
4157 ));
4158 }
4159
4160 // Parse merkle proof if provided
4161 let proof = if proof_hex.is_empty() {
4162 None
4163 } else {
4164 Some(
4165 beef_limits::merkle_path_from_hex(proof_hex, beef_limits::PUSH_PROOF_MAX_BYTES)
4166 .map_err(|e| EngineError::Other(format!("Invalid merkle proof hex: {e}")))?,
4167 )
4168 };
4169
4170 for output in &outputs {
4171 // Stitch the proof into the STORED BEEF (#284) — never rebuild it
4172 // around a re-picked subject; see stitch_proof_into_stored_beef.
4173 if let (Some(ref beef_data), Some(ref proof)) = (&output.beef, &proof) {
4174 if let Some(new_beef) = Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4175 {
4176 let _ = self
4177 .storage
4178 .update_transaction_beef(&output.txid, &new_beef)
4179 .await;
4180 }
4181 }
4182
4183 // Update block height
4184 if let Some(height) = block_height {
4185 let _ = self
4186 .storage
4187 .update_output_block_height(
4188 &output.txid,
4189 output.output_index,
4190 &output.topic,
4191 height,
4192 )
4193 .await;
4194 }
4195
4196 // Recursively update consumedBy chain
4197 for consuming in &output.consumed_by {
4198 if let Ok(consumed_outputs) = self
4199 .storage
4200 .find_outputs_for_transaction(&consuming.txid, true)
4201 .await
4202 {
4203 for consumed_output in &consumed_outputs {
4204 // The consuming row's BEEF carries this tx as an
4205 // ancestor — stitch the same bump in there too, again
4206 // without rebuilding the tx set (#284: this branch is
4207 // the one that was observed replacing a head-spend's
4208 // stored BEEF with parent ancestry only).
4209 if let (Some(ref beef_data), Some(ref proof)) =
4210 (&consumed_output.beef, &proof)
4211 {
4212 if let Some(new_beef) =
4213 Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4214 {
4215 let _ = self
4216 .storage
4217 .update_transaction_beef(&consumed_output.txid, &new_beef)
4218 .await;
4219 }
4220 }
4221 }
4222 }
4223 }
4224 }
4225
4226 Ok(())
4227 }
4228
4229 /// Complete missing merkle proofs for stored, confirmed transactions.
4230 ///
4231 /// The `/submit`-admitted ingest path stores a proofless BEEF for each
4232 /// transaction (the submitter does not yet have a merkle proof at submit
4233 /// time). When such a transaction later mines, nothing in the overlay
4234 /// re-fetches its proof, so its stored BEEF stays proofless forever — and a
4235 /// frontend that trims `inputBEEF` to `{source tx + its BUMP}` for a P2PKH
4236 /// payment-import spend has no BUMP to trim to, hanging the import (#130).
4237 ///
4238 /// This pass closes that gap from chain (the one blessed server-side WoC
4239 /// use). It:
4240 /// 1. Pulls a bounded page (`limit`) of stored `(txid, beef)` from storage.
4241 /// 2. Parses each BEEF and keeps only those whose target tx still lacks a
4242 /// merkle proof (`Beef::find_txid(txid).has_proof() == false`).
4243 /// 3. For each proofless candidate, fetches its BUMP via the configured
4244 /// [`AncestorFetcher`](crate::gasp::AncestorFetcher). If a proof comes
4245 /// back, it calls [`Self::handle_new_merkle_proof`], which stitches the
4246 /// BUMP into the stored BEEF (D1 + R2 mirror), updates output block
4247 /// height, and recurses the `consumedBy` chain.
4248 /// 4. Skips (does NOT error) txs the fetcher can't prove yet (still
4249 /// unconfirmed) — those are simply retried on a later tick.
4250 ///
4251 /// **No-op when no [`AncestorFetcher`] is configured** — production-safe
4252 /// default (the same opt-in switch as GASP ancestor hydration). Bounded by
4253 /// `limit` (a per-tick budget, like the platform fetcher's own budget).
4254 ///
4255 /// `min_age_secs` is the PUSH-PRIMARY BACKSTOP gate (bsv-low #228 /
4256 /// arcade#259): since the Arcade MINED webhook (`/arc-ingest`) pushes a
4257 /// verified proof ~150 ms after a tx mines, this poll pass is a BACKSTOP,
4258 /// not the primary source — rows stored less than `min_age_secs` ago are
4259 /// skipped entirely (their push is still expected). Pass `0` to disable
4260 /// the gate (poll everything — the pre-#228 behaviour, and the degradation
4261 /// mode if pushes stop: an old-enough row is ALWAYS polled, so webhook
4262 /// loss degrades to polling, never to nothing). Unknown-age rows are
4263 /// always eligible (fail-safe — see the storage-trait doc).
4264 pub async fn complete_missing_proofs(
4265 &self,
4266 limit: u64,
4267 min_age_secs: u64,
4268 ) -> Result<ProofCompletionSummary, EngineError> {
4269 let mut summary = ProofCompletionSummary::default();
4270
4271 // Production-safe: with no fetcher there is no chain source to complete
4272 // from, so this is a pure no-op (matches GASP-hydration opt-in).
4273 let Some(fetcher) = self.ancestor_fetcher.clone() else {
4274 return Ok(summary);
4275 };
4276
4277 let candidates = self
4278 .storage
4279 .find_transactions_for_proof_check(limit, min_age_secs)
4280 .await
4281 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4282 summary.scanned = candidates.len();
4283
4284 for cand in candidates {
4285 // Read the stored BEEF for the subject's OWN bump by streaming
4286 // folds (one element in hand, no `Beef` built: NL-6); skip
4287 // anything unparseable (don't error the whole pass on one bad row).
4288 let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4289 Ok(b) => b,
4290 Err(e) => {
4291 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4292 continue;
4293 }
4294 };
4295
4296 // Already structurally proven? The stored BEEF carries a merkle bump
4297 // for the target tx, but its backend `has_proof` flag may still be 0
4298 // — EITHER a legitimate GASP-synced proof written before overlay
4299 // migration 0010 (which defaulted every existing row to 0), OR an
4300 // admit-time bump that was NEVER SPV-verified (`/submit` skips SPV on
4301 // historical topics) or is outright forged. Serve-time BEEF trimming
4302 // trusts the `has_proof` flag, so latching one on STRUCTURE ALONE
4303 // would let a forged bump be trimmed on (#192/#193 MEDIUM). Re-verify
4304 // the STORED bump against chaintracks (via the fetcher — its header
4305 // source is the only arbiter of a merkle root). Genuine → latch
4306 // (fast, no re-fetch). Otherwise DON'T trust it: fall through to the
4307 // fetch+stitch path below, which overwrites the bump with a
4308 // chaintracks-verified one (or leaves the row proofless for retry).
4309 // Fail-closed throughout.
4310 // the tx's OWN bump (its `bump_index`), never `find_bump`
4311 // (the FIRST bump containing the txid — the stale one after a
4312 // same-height reorg; review HIGH-1).
4313 if let Some(bump_hex) = own_bump
4314 .as_ref()
4315 .map(bsv_rs::transaction::MerklePath::to_hex)
4316 {
4317 if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4318 // Idempotent + best-effort: a failure here is logged, not
4319 // fatal — the row simply lingers one more tick.
4320 // bsv-low M19 R2 round 3 (review MED-2): latch WITH the
4321 // anchor height (from the tx's OWN verified bump) so the
4322 // revalidation sweep's transactions leg can window this
4323 // row — `mark_transaction_proven` alone left every
4324 // fast-path row anchorless, in no window forever.
4325 let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4326 .ok()
4327 .map(|mp| u64::from(mp.block_height));
4328 if let Err(e) = self
4329 .storage
4330 .mark_transaction_proven_at(&cand.txid, height)
4331 .await
4332 {
4333 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4334 } else {
4335 summary.already_proven += 1;
4336 }
4337 continue;
4338 }
4339 warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4340 }
4341 // Unverifiable structural bump → treat as proofless (fall through).
4342 summary.proofless += 1;
4343
4344 // Fetch this tx's own VERIFIED bump from chain — PROOF ONLY (the raw
4345 // is already in the stored BEEF, so no redundant raw fetch; #192/#193
4346 // FIX 2). A tx with no verified proof yet (still unconfirmed, or an
4347 // unverifiable/forged courier response) is skipped, not errored.
4348 let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4349 summary.still_unconfirmed += 1;
4350 continue;
4351 };
4352
4353 // Pull the block height out of the BUMP so the output rows update too.
4354 let block_height =
4355 beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4356 .ok()
4357 .map(|mp| mp.block_height);
4358
4359 match self
4360 .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4361 .await
4362 {
4363 Ok(()) => summary.completed += 1,
4364 Err(e) => {
4365 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4366 summary.stitch_failed += 1;
4367 }
4368 }
4369 }
4370
4371 info!(
4372 scanned = summary.scanned,
4373 proofless = summary.proofless,
4374 completed = summary.completed,
4375 still_unconfirmed = summary.still_unconfirmed,
4376 fetch_failed = summary.fetch_failed,
4377 stitch_failed = summary.stitch_failed,
4378 already_proven = summary.already_proven,
4379 "[PROOF COMPLETION] pass complete"
4380 );
4381
4382 Ok(summary)
4383 }
4384
4385 /// Recursively update merkle paths in a transaction's input tree.
4386 ///
4387 /// If the transaction's id matches txid, set its merkle_path.
4388 /// Otherwise, recurse into sourceTransactions of each input.
4389 /// Stitch a verified BUMP for `txid` into a STORED BEEF, preserving every
4390 /// transaction and every other proof in it (#284).
4391 ///
4392 /// The old path round-tripped the stored bytes through
4393 /// `Transaction::from_beef(bytes, None)` -> `to_beef(true)`, and
4394 /// `from_beef(_, None)` picks **`txs.last()`** — it ignores the atomic
4395 /// subject pointer entirely. Whenever the subject was not the last tx in
4396 /// wire order, a PARENT was picked, `to_beef(true)` then serialized that
4397 /// parent's world, and the rewrite dropped the subject transaction from its
4398 /// own stored BEEF — observed live on every pf head-spend (list / reprice /
4399 /// delist / buy) the moment it mined, breaking the NEXT spend on that name
4400 /// (zanaadu#284). `update_input_proofs`' "already has a proof — update it"
4401 /// arm could even stamp the child's bump onto that mis-picked parent.
4402 /// This is the same destroy-the-stored-BEEF class the #4 fix note above
4403 /// (`hydrate_utxo_history`) already documents for lookup hydration.
4404 ///
4405 /// Operating on [`Beef`] directly has none of those failure modes:
4406 /// `merge_bump` dedupes against existing bumps by (height, root) and
4407 /// assigns the bump ONLY to transactions that appear as flagged txid
4408 /// leaves, so stitching tx X's proof can never mislabel tx Y, and the tx
4409 /// set is never rebuilt at all.
4410 ///
4411 /// Returns `None` when the bytes do not parse or `txid` is not in this
4412 /// BEEF (nothing to stitch — e.g. a consuming row whose ancestry got
4413 /// trimmed), so callers skip the write instead of writing garbage.
4414 ///
4415 /// bsv-low M19 R2 round 3 (review HIGH-1): a same-height DIFFERENT-root
4416 /// proof (the 2026-09-07 reorg shape) does NOT combine in `merge_bump`
4417 /// (the roots differ) — it is pushed as a SECOND bump, and
4418 /// `update_bump_indices` assigns it only to txs whose `bump_index` is
4419 /// `None`, so the reorged subject keeps its index on the STALE bump.
4420 /// Left alone, `has_proof`/`proofHeight` and every `find_txid(txid)
4421 /// .bump_index()` reader then keep pointing at the orphan bump, and the
4422 /// row refutes again next sweep pass forever. So after the merge, FORCE
4423 /// the subject onto the bump that actually proves it, then drop any bump
4424 /// no transaction references (the orphan's, now unreferenced).
4425 pub fn stitch_proof_into_stored_beef(
4426 stored: &[u8],
4427 txid: &str,
4428 proof: &bsv_rs::transaction::MerklePath,
4429 ) -> Option<Vec<u8>> {
4430 let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4431 beef.find_txid(txid)?;
4432 let i = beef.merge_bump(proof.clone());
4433 // Does the merged bump actually prove `txid` (a flagged txid leaf)?
4434 // (It always does for a completion proof; the guard keeps a stray
4435 // caller from mis-anchoring a subject onto an ancestor's bump.)
4436 let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4437 b.path.first().is_some_and(|leaves| {
4438 leaves
4439 .iter()
4440 .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4441 })
4442 });
4443 if proves_subject {
4444 if let Some(tx) = beef.find_txid_mut(txid) {
4445 tx.set_bump_index(Some(i));
4446 }
4447 }
4448 Self::gc_unreferenced_bumps(&mut beef);
4449 Some(beef.to_binary())
4450 }
4451
4452 /// Drop every BUMP no transaction references and re-index the survivors
4453 /// (bsv-low M19 R2 round 3, review HIGH-1): after the subject is moved
4454 /// off an orphan bump, that bump is usually dead weight the next reader
4455 /// would still `find_bump`. A bump still referenced by ANOTHER tx (a
4456 /// sibling mined in the same block) is kept.
4457 fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4458 let referenced: std::collections::HashSet<usize> = beef
4459 .txs
4460 .iter()
4461 .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4462 .collect();
4463 if referenced.len() == beef.bumps.len() {
4464 return;
4465 }
4466 let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4467 let mut kept = Vec::with_capacity(referenced.len());
4468 for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4469 if referenced.contains(&old) {
4470 remap[old] = Some(kept.len());
4471 kept.push(bump);
4472 }
4473 }
4474 beef.bumps = kept;
4475 for tx in &mut beef.txs {
4476 if let Some(old) = tx.bump_index() {
4477 tx.set_bump_index(remap[old]);
4478 }
4479 }
4480 }
4481
4482 // ========================================================================
4483 // Advertisement sync
4484 // ========================================================================
4485
4486 /// Sync SHIP/SLAP advertisements with configured managers and services.
4487 ///
4488 /// Creates missing advertisements and revokes stale ones. Returns a
4489 /// [`SyncAdvertisementsReport`] so a create/submit failure is VISIBLE to
4490 /// the caller (bsv-low #320 defect 3a): this method used to swallow both
4491 /// into `error!` and return `Ok(())`, so `/admin/syncAdvertisements`
4492 /// reported `success` while zero advertisements were admitted locally —
4493 /// a caller could not tell a converged no-op from a silent failure, and
4494 /// every cycle re-created the full ad set.
4495 pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4496 let mut report = SyncAdvertisementsReport::default();
4497
4498 let Some(advertiser) = &self.advertiser else {
4499 return Ok(report); // No advertiser configured
4500 };
4501
4502 let hosting_url = match &self.config.hosting_url {
4503 Some(url) if !url.is_empty() => url.clone(),
4504 _ => return Ok(report), // No hosting URL
4505 };
4506
4507 // Get configured topics and services
4508 let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4509 let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4510
4511 if self.config.suppress_default_sync_advertisements {
4512 configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4513 configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4514 }
4515
4516 // Fetch current advertisements. A read failure REFUSES creation
4517 // (#320 M2): swallowing it into "current ads = none" made a
4518 // transient D1/storage fault re-create — and re-pay for — the
4519 // entire advertisement set, reported as a legitimate `to_create`.
4520 let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4521 Ok(ads) => ads,
4522 Err(e) => {
4523 error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4524 report.lookup_error = Some(format!("ship: {e}"));
4525 return Ok(report);
4526 }
4527 };
4528 let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4529 Ok(ads) => ads,
4530 Err(e) => {
4531 error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4532 report.lookup_error = Some(format!("slap: {e}"));
4533 return Ok(report);
4534 }
4535 };
4536
4537 // Determine what to create
4538 let ships_to_create: Vec<AdvertisementData> = configured_topics
4539 .iter()
4540 .filter(|topic| {
4541 !current_ship
4542 .iter()
4543 .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4544 })
4545 .map(|topic| AdvertisementData {
4546 protocol: Protocol::Ship,
4547 topic_or_service_name: topic.clone(),
4548 })
4549 .collect();
4550
4551 let slaps_to_create: Vec<AdvertisementData> = configured_services
4552 .iter()
4553 .filter(|service| {
4554 !current_slap
4555 .iter()
4556 .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4557 })
4558 .map(|service| AdvertisementData {
4559 protocol: Protocol::Slap,
4560 topic_or_service_name: service.clone(),
4561 })
4562 .collect();
4563
4564 // Determine what to revoke
4565 let ships_to_revoke: Vec<Advertisement> = current_ship
4566 .into_iter()
4567 .filter(|a| !configured_topics.contains(&a.topic_or_service))
4568 .collect();
4569
4570 let slaps_to_revoke: Vec<Advertisement> = current_slap
4571 .into_iter()
4572 .filter(|a| !configured_services.contains(&a.topic_or_service))
4573 .collect();
4574
4575 // Create new advertisements
4576 let mut all_to_create = ships_to_create;
4577 all_to_create.extend(slaps_to_create);
4578 report.to_create = all_to_create.len();
4579 if !all_to_create.is_empty() {
4580 match advertiser.create_advertisements(&all_to_create).await {
4581 Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4582 Ok(steak) => {
4583 for (topic, instructions) in &steak {
4584 report
4585 .admitted
4586 .insert(topic.clone(), instructions.outputs_to_admit.len());
4587 }
4588 }
4589 Err(e) => {
4590 error!("Failed to submit new advertisements: {e}");
4591 report.submit_error = Some(e.to_string());
4592 }
4593 },
4594 Err(e) => {
4595 error!("Failed to create advertisements: {e}");
4596 report.create_error = Some(e.to_string());
4597 }
4598 }
4599 }
4600
4601 // Revoke stale advertisements
4602 let mut all_to_revoke = ships_to_revoke;
4603 all_to_revoke.extend(slaps_to_revoke);
4604 report.to_revoke = all_to_revoke.len();
4605 if !all_to_revoke.is_empty() {
4606 match advertiser.revoke_advertisements(&all_to_revoke).await {
4607 // An advertiser that declines to build a revocation (e.g. the
4608 // CF advertiser's documented v1 no-op) returns an EMPTY
4609 // TaggedBEEF — submitting it would only manufacture a parse
4610 // error. Skip, but say so in the report (#320 L2): the stale
4611 // ads stay on-chain until they age out.
4612 Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4613 report.revoke_skipped = all_to_revoke.len();
4614 }
4615 Ok(tagged_beef) => {
4616 if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4617 error!("Failed to submit revocation: {e}");
4618 report.revoke_submit_error = Some(e.to_string());
4619 }
4620 }
4621 Err(e) => {
4622 error!("Failed to revoke advertisements: {e}");
4623 report.revoke_error = Some(e.to_string());
4624 }
4625 }
4626 }
4627
4628 Ok(report)
4629 }
4630
4631 // ========================================================================
4632 // Deep deletion
4633 // ========================================================================
4634
4635 /// Recursively delete a UTXO and all stale consumed inputs.
4636 ///
4637 /// Only deletes if the output has no remaining consumers (consumedBy is empty).
4638 /// Then recurses into outputsConsumed, removing the deleted output from their
4639 /// consumedBy lists and deleting them if they become unreferenced.
4640 #[allow(dead_code)] // Used by submit() once BEEF parsing is wired up
4641 ///
4642 /// `bound` is a finalize submit's [`CallBound`] over each call here
4643 /// (bsv-low #559); `None` everywhere else.
4644 fn delete_utxo_deep<'a>(
4645 &'a self,
4646 output: &'a Output,
4647 bound: Option<&'a CallBound>,
4648 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4649 Box::pin(async move {
4650 // Only delete if nothing consumes this output
4651 if output.consumed_by.is_empty() {
4652 stored(
4653 bound,
4654 self.storage
4655 .delete_output(&output.txid, output.output_index, &output.topic),
4656 )
4657 .await
4658 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4659
4660 // Notify lookup services. A hook error is dropped, not
4661 // reported (parity: the reference catches it, best effort).
4662 for ls in self.lookup_services.values() {
4663 let _ = hooked(
4664 bound,
4665 ls.output_no_longer_retained_in_history(
4666 &output.txid,
4667 output.output_index,
4668 &output.topic,
4669 ),
4670 )
4671 .await;
4672 }
4673 }
4674
4675 // Recurse into consumed outputs
4676 for consumed in &output.outputs_consumed {
4677 if let Ok(Some(mut stale_output)) = stored(
4678 bound,
4679 self.storage.find_output(
4680 &consumed.txid,
4681 consumed.output_index,
4682 Some(&output.topic),
4683 None,
4684 false,
4685 ),
4686 )
4687 .await
4688 {
4689 // Remove the deleted output from consumedBy
4690 stale_output.consumed_by.retain(|c| {
4691 !(c.txid == output.txid && c.output_index == output.output_index)
4692 });
4693
4694 stored(
4695 bound,
4696 self.storage.update_consumed_by(
4697 &consumed.txid,
4698 consumed.output_index,
4699 &output.topic,
4700 &stale_output.consumed_by,
4701 ),
4702 )
4703 .await
4704 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4705
4706 // Recurse
4707 let _ = self.delete_utxo_deep(&stale_output, bound).await;
4708 }
4709 }
4710
4711 Ok(())
4712 }) // Box::pin
4713 }
4714
4715 // ========================================================================
4716 // Eviction
4717 // ========================================================================
4718
4719 /// Evict a specific outpoint from the overlay.
4720 ///
4721 /// 1. Deletes the output from storage for the given topic.
4722 /// 2. Notifies all lookup services via `output_evicted()`.
4723 ///
4724 /// If no topic is specified, finds all outputs for the txid and evicts the
4725 /// matching outputIndex across every topic.
4726 ///
4727 /// Matches TS OverlayExpress `/admin/evictOutpoint` (lines 974-1004).
4728 /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): RE-NOTIFY every lookup service
4729 /// of the outputs the engine ALREADY admitted for (`txid`, `topic`).
4730 ///
4731 /// Phase-1 dedup means a (txid, topic) once recorded in
4732 /// `applied_transactions` never re-enters through `/submit`, so a lookup
4733 /// service that missed — or mis-parsed — its admit notification could
4734 /// never be told again (the loop-2 subject trap: the pot index's admit
4735 /// hook shape-checked a wire-last hop and wrote no record while the engine
4736 /// had admitted the pot). This replays `output_admitted_by_topic` from the
4737 /// engine's OWN stored outputs (with their BEEF) and the subject-NAMED body
4738 /// exactly as `submit_with_report` sends it. It validates nothing anew
4739 /// (the topic manager judged these outputs at admit), writes none of the
4740 /// engine's tables, and is idempotent for every lookup service whose
4741 /// writes are (the pot index's `store_record` is insert-if-absent for the
4742 /// spend fields — re-admission never regresses spend state).
4743 /// `outputs == 0` means the engine never admitted this txid on this topic:
4744 /// nothing to re-notify — the caller re-SUBMITS instead.
4745 pub async fn renotify_admitted(
4746 &self,
4747 txid: &str,
4748 topic: &str,
4749 ) -> Result<RenotifyReport, EngineError> {
4750 let txid = txid.to_ascii_lowercase();
4751 let outputs = self
4752 .storage
4753 .find_outputs_for_transaction(&txid, true)
4754 .await
4755 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4756 let mut report = RenotifyReport::default();
4757 for output in outputs.iter().filter(|o| o.topic == topic) {
4758 report.outputs += 1;
4759 report.vouts.push(output.output_index);
4760 // the body every WholeTx service receives NAMES the subject (BRC-95
4761 // atomic prefix) — the same rule as the live submit path
4762 let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4763 beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4764 .ok()
4765 .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4766 .unwrap_or_else(|| beef.clone())
4767 });
4768 for (name, ls) in &self.lookup_services {
4769 let payload = match ls.admission_mode() {
4770 AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4771 txid: txid.clone(),
4772 output_index: output.output_index,
4773 topic: topic.to_string(),
4774 satoshis: output.satoshis,
4775 locking_script: output.output_script.clone(),
4776 off_chain_values: None,
4777 },
4778 AdmissionMode::WholeTx => {
4779 let Some(atomic_beef) = subject_named.as_ref() else {
4780 report.faults.push(format!(
4781 "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4782 output.output_index
4783 ));
4784 continue;
4785 };
4786 OutputAdmittedByTopic::WholeTx {
4787 atomic_beef: atomic_beef.clone(),
4788 output_index: output.output_index,
4789 topic: topic.to_string(),
4790 off_chain_values: None,
4791 }
4792 }
4793 };
4794 match ls.output_admitted_by_topic(&payload).await {
4795 Ok(()) => report.notified += 1,
4796 Err(e) => report.faults.push(format!("{name}: {e}")),
4797 }
4798 }
4799 }
4800 Ok(report)
4801 }
4802
4803 /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): FORGET a PHANTOM applied row —
4804 /// (txid, topic) recorded in `applied_transactions` while the topic holds
4805 /// NO output of that txid (the engine judged the tx and admitted nothing:
4806 /// the loop-2 JOIN `f9e85aab…` under the pre-D5 subject rule). Such a row
4807 /// does nothing but block a correct re-submit through the Phase-1 dedup
4808 /// (`/admin/readmit` on beta answered `deduped: ["tm_pot_beta"]` and the
4809 /// pot stayed `known:false`). A row with ANY stored output on the topic is
4810 /// a real admission and is never touched (returns false, as does a missing
4811 /// row). The forgotten bytes are then re-VALIDATED by the topic manager
4812 /// like any first submit — nothing is admitted here.
4813 pub async fn forget_phantom_applied(
4814 &self,
4815 txid: &str,
4816 topic: &str,
4817 ) -> Result<bool, EngineError> {
4818 let txid = txid.to_ascii_lowercase();
4819 let outputs = self
4820 .storage
4821 .find_outputs_for_transaction(&txid, false)
4822 .await
4823 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4824 if outputs.iter().any(|o| o.topic == topic) {
4825 return Ok(false);
4826 }
4827 let rec = AppliedTransaction {
4828 txid: txid.clone(),
4829 topic: topic.to_string(),
4830 };
4831 let exists = self
4832 .storage
4833 .does_applied_transaction_exist(&rec)
4834 .await
4835 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4836 if !exists {
4837 return Ok(false);
4838 }
4839 self.storage
4840 .delete_applied_transaction(&rec)
4841 .await
4842 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4843 Ok(true)
4844 }
4845
4846 pub async fn evict_output(
4847 &self,
4848 txid: &str,
4849 output_index: u32,
4850 topic: Option<&str>,
4851 ) -> Result<(), EngineError> {
4852 if let Some(topic) = topic {
4853 // Delete from storage
4854 self.storage
4855 .delete_output(txid, output_index, topic)
4856 .await
4857 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4858
4859 // Notify all lookup services
4860 for ls in self.lookup_services.values() {
4861 let _ = ls.output_evicted(txid, output_index).await;
4862 }
4863 } else {
4864 // No topic specified — find all outputs for this txid and evict matching ones
4865 let outputs = self
4866 .storage
4867 .find_outputs_for_transaction(txid, false)
4868 .await
4869 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4870
4871 for output in &outputs {
4872 if output.output_index == output_index {
4873 self.storage
4874 .delete_output(txid, output_index, &output.topic)
4875 .await
4876 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4877 }
4878 }
4879
4880 // Notify all lookup services once
4881 for ls in self.lookup_services.values() {
4882 let _ = ls.output_evicted(txid, output_index).await;
4883 }
4884 }
4885
4886 Ok(())
4887 }
4888
4889 // ========================================================================
4890 // GASP sync
4891 // ========================================================================
4892
4893 /// Start GASP synchronization with peers for all configured topics.
4894 ///
4895 /// For each topic in `sync_configuration`:
4896 /// - `SyncTarget::Ship`: unions local SHIP records, network discovery via
4897 /// configured SLAP trackers, and the topic's bootstrap peers.
4898 /// - `SyncTarget::Peers(urls)` — uses the provided peer URLs directly.
4899 /// - `SyncTarget::Disabled` — skips the topic.
4900 ///
4901 /// Filters out our own `hosting_url` to avoid self-sync.
4902 ///
4903 /// Returns a `GASPSyncResult` summarizing discovered peers per topic.
4904 ///
4905 /// Discovers peers for each configured topic and runs GASP sync with each.
4906 ///
4907 /// When a `GASPRemoteFactory` is set (via `set_gasp_remote_factory`), this
4908 /// method creates `OverlayGASPStorage` + `GASPRemote` instances per
4909 /// (topic, peer) pair and runs `GASPSync::sync()` to exchange UTXOs.
4910 ///
4911 /// Without a factory, peer discovery still runs but no actual sync occurs
4912 /// (backwards-compatible with the previous discovery-only behavior).
4913 pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
4914 use crate::gasp::{GASPSync, ENGINE_GASP_SYNC_PAGE_LIMIT};
4915 use crate::gasp_overlay::OverlayGASPStorage;
4916
4917 if self.config.sync_configuration.is_empty() {
4918 info!("[GASP SYNC] No sync configuration — nothing to sync");
4919 return Ok(GASPSyncResult {
4920 topics_synced: HashMap::new(),
4921 });
4922 }
4923
4924 let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
4925 // Resolve SLAP providers once per invocation, sharing their host cap
4926 // across all SHIP topics. Every topic still queries each lookup host.
4927 let mut ship_lookup_hosts = None;
4928 let mut topic_order = sync_order(&self.config.sync_configuration);
4929 // Explicit bootstrap-backed SHIP topics run ahead of large tracker
4930 // imports. Stable sorting preserves the existing order elsewhere.
4931 topic_order.sort_by_key(|(topic, target)| {
4932 !(matches!(target, SyncTarget::Ship)
4933 && self
4934 .ship_bootstrap_peers
4935 .get(topic.as_str())
4936 .is_some_and(|peers| !peers.is_empty()))
4937 });
4938
4939 for (topic, target) in topic_order {
4940 // A CONFIGURED topic's peers are reserved part of a storage's
4941 // deferred-graph ceiling (bsv-low #555, the delta-2 fold's D2-M2).
4942 let configured_peers = matches!(target, SyncTarget::Peers(_));
4943 let (peers, sync_type) = match target {
4944 SyncTarget::Disabled => {
4945 info!("[GASP SYNC] Topic {topic} is disabled — skipping");
4946 continue;
4947 }
4948 SyncTarget::Ship => {
4949 info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
4950 let peers = self
4951 .discover_ship_peers(topic, &mut ship_lookup_hosts)
4952 .await;
4953 info!(
4954 "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
4955 peers.len()
4956 );
4957 (peers, "ship".to_string())
4958 }
4959 SyncTarget::Peers(peer_urls) => {
4960 info!(
4961 "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
4962 peer_urls.len()
4963 );
4964 let peers: Vec<String> = peer_urls
4965 .iter()
4966 .filter(|url| {
4967 if let Some(ref our_url) = self.config.hosting_url {
4968 url.trim_end_matches('/') != our_url.trim_end_matches('/')
4969 } else {
4970 true
4971 }
4972 })
4973 .cloned()
4974 .collect();
4975
4976 info!(
4977 "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
4978 peers.len()
4979 );
4980 (peers, "peers".to_string())
4981 }
4982 };
4983
4984 let mut errors = Vec::new();
4985 let mut pruned_inputs: u64 = 0;
4986 let mut discarded_graphs: u64 = 0;
4987 let mut anchor_walk_held_graphs: u64 = 0;
4988 let mut finalized_graphs: u64 = 0;
4989 let mut deadline_dropped_graphs: u64 = 0;
4990 let mut cursor_moves: Vec<CursorMove> = Vec::new();
4991 let mut deferred_graphs: u64 = 0;
4992 let mut resumed_graphs: u64 = 0;
4993 let mut converged_graphs: u64 = 0;
4994 let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
4995 let mut stalled_graphs: u64 = 0;
4996 let mut held_back_graphs: u64 = 0;
4997
4998 // If we have a remote factory, actually run GASP sync
4999 if let Some(ref factory) = self.gasp_remote_factory {
5000 for peer_url in &peers {
5001 // bsv-low#302 quarantine gate: a peer at
5002 // PEER_QUARANTINE_THRESHOLD consecutive failures is
5003 // SKIPPED (no attempt recorded — its last-attempt age
5004 // keeps growing) until the re-probe window opens. A
5005 // health-read FAULT treats the peer as healthy: broken
5006 // bookkeeping must never silence a live peer.
5007 //
5008 // The peer's health (its quarantine, its yieldless
5009 // streak) is keyed on its NORMALIZED ORIGIN (bsv-low
5010 // #555, the delta-2 fold's D2-M2): keyed on the URL, one
5011 // server was a fresh peer for every spelling it
5012 // advertised.
5013 let health_key = crate::gasp::peer_origin(peer_url);
5014 let health = self
5015 .storage
5016 .get_peer_sync_health(&health_key, topic)
5017 .await
5018 .unwrap_or_default();
5019 if crate::gasp::peer_sync_quarantined(&health) {
5020 warn!(
5021 "[GASP SYNC] Peer {peer_url} for {topic} QUARANTINED ({} consecutive failed syncs, last attempt {:?}s ago) — skipped until the {}s re-probe window (bsv-low#302)",
5022 health.consecutive_failures,
5023 health.secs_since_last_attempt,
5024 crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5025 );
5026 continue;
5027 }
5028
5029 info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
5030
5031 // Get last interaction score for this (peer, topic) pair
5032 let last_interaction = self
5033 .storage
5034 .get_last_interaction(peer_url, topic)
5035 .await
5036 .unwrap_or(0);
5037
5038 // Create shared sink for finalized graphs
5039 let sink = crate::gasp_overlay::new_finalized_graph_sink();
5040
5041 // OPT-IN ancestor hydration: only when a fetcher is
5042 // configured do we enable chain-backed ancestor fallback +
5043 // strict-BEEF finalize. Default (None) = byte-identical to
5044 // today (tolerant BEEF, peer errors abandon the graph;
5045 // the one exception is the D8 prune of a manager-named
5046 // input the peer answers it does not hold, which exists
5047 // only WITHOUT a fetcher, see
5048 // `GASPSync::process_incoming_node`).
5049 let hydration_on = self.ancestor_fetcher.is_some();
5050
5051 // Create storage adapter and remote
5052 let mut gasp_storage =
5053 OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5054 .with_peer(peer_url.as_str())
5055 .with_configured_peer(configured_peers)
5056 .with_strict_beef(hydration_on)
5057 .with_script_verification(self.verify_scripts)
5058 .with_walk_budget(self.walk_budget);
5059 if let Some(manager) = self.managers.get(topic) {
5060 gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5061 }
5062 // bsv-low #551: the anchor check verifies each graph's
5063 // root against THIS engine's tracker, with this engine's
5064 // script switch, before finalize. No tracker is the
5065 // reference's 'scripts only'.
5066 if let Some(tracker) = self.chain_tracker.as_deref() {
5067 gasp_storage = gasp_storage.with_chain_tracker(tracker);
5068 }
5069 let gasp_remote = factory.create_remote(peer_url, topic);
5070
5071 let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5072 // Graphs of this peer handed to submit (bsv-low #552).
5073 let peer_finalized = std::cell::Cell::new(0u64);
5074 let mut sync = GASPSync::new(
5075 Box::new(gasp_storage),
5076 gasp_remote,
5077 last_interaction,
5078 &log_prefix,
5079 true, // unidirectional — overlay GASP is pull-only (submitNode throws); matches TS Engine.startGASPSync
5080 )
5081 .with_ancestor_fetcher(self.ancestor_fetcher.clone());
5082 // bsv-low #555: a graph past its own budget is deferred,
5083 // not dropped with the sync.
5084 if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5085 let (sleep, budget_ms) = (sleep.clone(), *budget_ms);
5086 let half_sleep = sleep.clone();
5087 sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5088 max_calls: *max_calls,
5089 max_bytes_fetched: self.graph_budget_limbs.0,
5090 max_nodes: self.graph_budget_limbs.1,
5091 deadline: Box::new(move || sleep(budget_ms)),
5092 half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5093 });
5094 }
5095 // bsv-low #552: under a budget each graph is submitted as
5096 // it finalizes, so the deadline cannot take it back. With
5097 // no budget nothing can drop the sync, and the graphs are
5098 // submitted after it as they always were.
5099 if self.peer_sync_budget.is_some() {
5100 sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5101 engine: self,
5102 sink: sink.clone(),
5103 peer_url,
5104 topic,
5105 submitted: &peer_finalized,
5106 }));
5107 }
5108
5109 // bsv-low#302: bound THIS peer's sync to the configured
5110 // budget. `None` from the race = the budget won and the
5111 // sync future was dropped mid-flight. What it finalized
5112 // before that is already submitted (the hook above); the
5113 // cursor then advances to `completed_cursor` only, see
5114 // the deadline arm below. The race is GUARDED: it never
5115 // drops the sync inside one transaction's finalize
5116 // submit (the lens fold's HIGH-1), it waits for that
5117 // transaction and the hook stops at the boundary.
5118 let sync_outcome = match &self.peer_sync_budget {
5119 Some((sleep, budget_ms)) => {
5120 crate::gasp::race_or_deadline_guarded(
5121 sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5122 sleep(*budget_ms),
5123 &self.finalize_gate,
5124 )
5125 .await
5126 }
5127 None => Some(sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)).await),
5128 };
5129
5130 let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5131 // D8 decoy rule: branches pruned in this peer's walk. Not
5132 // an error and not a failed UTXO, so it is counted apart
5133 // from `errors` and never touches the outcome or cursor.
5134 // Summed over peers: one decoy seen through two peers
5135 // counts twice.
5136 pruned_inputs += sync.pruned_inputs();
5137 // bsv-low #551: graphs the anchor check refused. Counted
5138 // apart from `errors` for the same reason as a prune: a
5139 // refused graph is not a failed sync (the reference
5140 // discards it and carries on).
5141 discarded_graphs += sync.discarded_graphs();
5142 anchor_walk_held_graphs += sync.anchor_walk_held();
5143 // Whether the sync ran (to its end, or to the deadline)
5144 // and whether it moved the cursor (bsv-low #555, the
5145 // delta fold's D-M2).
5146 let ran = !matches!(sync_outcome, Some(Err(_)));
5147 let mut advanced_cursor = false;
5148 match sync_outcome {
5149 None => {
5150 let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5151 let msg = format!(
5152 "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5153 );
5154 warn!("[GASP SYNC] {msg}");
5155 errors.push(msg);
5156
5157 // bsv-low #552: the work COMPLETED before the
5158 // deadline is kept. Its graphs are already
5159 // submitted (the hook); the cursor moves past the
5160 // UTXOs that were completed and stops below the
5161 // one in flight, whose graph is lost whole and
5162 // walked again next tick. A graph the deadline
5163 // met while it was being SUBMITTED is not "in
5164 // flight" here: it left a prefix of whole
5165 // transactions, is counted finalized, and its
5166 // UTXO is below the cursor too.
5167 let in_flight = u64::from(sync.graph_in_flight());
5168 deadline_dropped_graphs += in_flight;
5169 // bsv-low #555: the walk in hand is saved, so the
5170 // next tick resumes it instead of walking it again.
5171 sync.defer_in_flight().await;
5172 let completed = sync.completed_cursor();
5173 if completed > last_interaction {
5174 match self
5175 .storage
5176 .update_last_interaction(peer_url, topic, completed)
5177 .await
5178 {
5179 Ok(()) => cursor_moves.push(CursorMove {
5180 peer: peer_url.clone(),
5181 from: last_interaction,
5182 to: completed,
5183 }),
5184 Err(e) => warn!(
5185 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5186 ),
5187 }
5188 }
5189 // A tick that finalized a graph or moved the
5190 // cursor reached a live peer: not a failed
5191 // attempt for the quarantine count. So did one
5192 // whose walk PROGRESSED (bsv-low #555: it
5193 // appended a node, or completed its graph). A
5194 // deferral that fetched nothing is no progress
5195 // (the lens fold's H1: a hung peer deferred with
5196 // 0 nodes on every tick and was never
5197 // quarantined).
5198 advanced_cursor = completed > last_interaction;
5199 outcome_success = peer_finalized.get() > 0
5200 || completed > last_interaction
5201 || sync.deferral_stats().progressed > 0;
5202 warn!(
5203 "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5204 peer_finalized.get(),
5205 completed.max(last_interaction)
5206 );
5207 }
5208 Some(Ok(())) => {
5209 // Submit finalized graphs to the Engine (under a
5210 // budget the hook already did, graph by graph,
5211 // and the sink is empty here).
5212 let landed = self
5213 .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5214 .await;
5215 // bsv-low #555, the lens fold's H1: a sync that
5216 // ran to its end with walks the per-graph budget
5217 // cut having FETCHED NOTHING, and nothing else
5218 // got done (no walk progressed, no graph
5219 // finalized, the cursor did not move), reached
5220 // a peer that does not answer: a FAILED attempt.
5221 // Before #555 such a peer held the sync to the
5222 // per-peer deadline, a failure; the per-graph
5223 // budget had made it an `Ok` sync. A sync whose
5224 // UTXOs failed in any other way is unchanged.
5225 let stats = sync.deferral_stats();
5226 if stats.stalled > 0
5227 && stats.progressed == 0
5228 && peer_finalized.get() == 0
5229 && !(landed && sync.last_interaction > last_interaction)
5230 {
5231 outcome_success = false;
5232 let msg = format!(
5233 "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5234 stats.stalled
5235 );
5236 warn!("[GASP SYNC] {msg}");
5237 errors.push(msg);
5238 }
5239
5240 // Advance the persisted cursor when it moved forward — i.e.
5241 // the peer reported UTXOs at a higher score than our last
5242 // `since` (the in-memory cursor only grows on SEEN scores,
5243 // so `>` already excludes the empty-bootstrap case where no
5244 // UTXOs were seen). Mirrors TS `if (gasp.lastInteraction >
5245 // lastInteraction)` — NOT gated on a new submission: a sync
5246 // that re-sees already-known UTXOs must still advance, else
5247 // every cron re-scans the same range forever (the cursor
5248 // stayed pinned at 0 in prod).
5249 //
5250 // NOT advanced when a finalize submit above did
5251 // not land (the lens fold's MEDIUM-1): the sink
5252 // does not say which UTXO that graph was, so the
5253 // whole cursor stays and the next sync is served
5254 // the range again (what did land is known and
5255 // skipped). Under a budget the hook fails that
5256 // one UTXO instead and the gap guard has already
5257 // capped `sync.last_interaction` below it.
5258 advanced_cursor = landed && sync.last_interaction > last_interaction;
5259 if !landed {
5260 warn!(
5261 "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5262 );
5263 } else if sync.last_interaction > last_interaction {
5264 match self
5265 .storage
5266 .update_last_interaction(peer_url, topic, sync.last_interaction)
5267 .await
5268 {
5269 Ok(()) => cursor_moves.push(CursorMove {
5270 peer: peer_url.clone(),
5271 from: last_interaction,
5272 to: sync.last_interaction,
5273 }),
5274 Err(e) => warn!(
5275 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5276 ),
5277 }
5278 }
5279 info!(
5280 "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5281 sync.last_interaction
5282 );
5283 }
5284 Some(Err(e)) => {
5285 let msg = format!("{peer_url}: {e}");
5286 warn!("[GASP SYNC] Sync failed: {msg}");
5287 errors.push(msg);
5288 }
5289 }
5290 // bsv-low #555, the delta fold's D-M2: "progress" (a node
5291 // appended) is the peer's word. A sync the peer served
5292 // work that finalized no graph and moved no cursor is
5293 // YIELDLESS; past PEER_YIELDLESS_SYNCS_ALLOWED of them in
5294 // a row each is a FAILED attempt, so a peer that serves
5295 // one fresh root a tick and hangs on its input is
5296 // quarantined. A quiet sync (nothing served) leaves the
5297 // count; a fault of the count is fail-safe (no failure).
5298 // Only with a per-graph budget: without one nothing is
5299 // deferred and #302's rule is unchanged.
5300 if ran && self.graph_budget.is_some() {
5301 let yielded = peer_finalized.get() > 0 || advanced_cursor;
5302 if yielded || sync.graphs_attempted() > 0 {
5303 match self
5304 .storage
5305 .record_peer_sync_yield(&health_key, topic, yielded)
5306 .await
5307 {
5308 Ok(streak)
5309 if !yielded
5310 && crate::gasp::yieldless_sync_failed(&streak)
5311 && outcome_success =>
5312 {
5313 outcome_success = false;
5314 let msg = format!(
5315 "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5316 streak.yieldless_syncs,
5317 streak.secs_since_first.unwrap_or(0),
5318 crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5319 crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5320 );
5321 warn!("[GASP SYNC] {msg}");
5322 errors.push(msg);
5323 }
5324 Ok(_) => {}
5325 Err(e) => warn!(
5326 "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5327 ),
5328 }
5329 }
5330 }
5331 finalized_graphs += peer_finalized.get();
5332 let deferral = sync.deferral_stats();
5333 deferred_graphs += deferral.deferred;
5334 resumed_graphs += deferral.resumed;
5335 converged_graphs += deferral.converged;
5336 stalled_graphs += deferral.stalled;
5337 held_back_graphs += deferral.held_back;
5338 dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5339 peer: peer_url.clone(),
5340 outpoint: d.outpoint,
5341 reason: d.reason.as_str().to_string(),
5342 }));
5343
5344 // bsv-low#302: record the attempt outcome (success resets
5345 // the consecutive-failure count; timeout/error increments
5346 // it). Best-effort — a bookkeeping fault must never fail
5347 // the sync pass itself.
5348 //
5349 // bsv-low #555, the delta-4 fold's D4-L2: with a
5350 // per-graph budget, a peer that was at the quarantine
5351 // threshold when this sync began is re-admitted only by
5352 // a YIELD (a graph finalized, the cursor moved). A sync
5353 // that did not fail and yielded nothing (an empty
5354 // listing at its probe, a walk that only progressed) is
5355 // NOT RECORDED: the failures stay, and so does the
5356 // last-attempt stamp, so the peer is attended again on
5357 // the next tick and its next failure re-arms the
5358 // quarantine at once. Recorded as a success, a hostile
5359 // peer's empty listing at each probe reset its failures
5360 // and (the decay being for a peer that is not failed)
5361 // its yieldless streak: 100 of 200 ticks. A health read
5362 // that faulted read 0 failures: the old rule, fail-safe.
5363 if outcome_success
5364 && !(peer_finalized.get() > 0 || advanced_cursor)
5365 && self.graph_budget.is_some()
5366 && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5367 {
5368 info!(
5369 "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5370 health.consecutive_failures
5371 );
5372 } else if let Err(e) = self
5373 .storage
5374 .record_peer_sync_outcome(&health_key, topic, outcome_success)
5375 .await
5376 {
5377 warn!(
5378 "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5379 );
5380 }
5381 }
5382 }
5383
5384 topics_synced.insert(
5385 topic.clone(),
5386 TopicSyncResult {
5387 peers,
5388 sync_type,
5389 errors,
5390 pruned_inputs,
5391 discarded_graphs,
5392 anchor_walk_held_graphs,
5393 finalized_graphs,
5394 deadline_dropped_graphs,
5395 cursor_moves,
5396 deferred_graphs,
5397 resumed_graphs,
5398 converged_graphs,
5399 dropped_graphs,
5400 stalled_graphs,
5401 held_back_graphs,
5402 },
5403 );
5404 }
5405
5406 info!(
5407 "[GASP SYNC] Peer discovery complete for {} topic(s)",
5408 topics_synced.len()
5409 );
5410
5411 Ok(GASPSyncResult { topics_synced })
5412 }
5413
5414 /// Drain `sink` and submit every finalized graph in it, ancestors first
5415 /// (`historical-tx-no-spv`: the anchor check already verified them).
5416 /// Each graph is added to `submitted` as its first submit starts. Returns
5417 /// whether EVERY transaction of every graph landed.
5418 ///
5419 /// Each graph here passed the anchor check: its root verified and the
5420 /// replay admitted it, so these submits rest on a checked premise and are
5421 /// not expected to fail. If one does not LAND, the REST OF THAT GRAPH is
5422 /// not submitted: the reference's `finalizeGraph` awaits each submit in
5423 /// order and its throw ends the loop (the BEEFs already submitted stay,
5424 /// it has no rollback either). A later BEEF would otherwise be judged
5425 /// without the coin its ancestor failed to leave, admit nothing and be
5426 /// recorded as applied, a dupe for good. "Landed" is read from the
5427 /// durability report, not from `Ok`: `submit` answers `Ok` when a storage
5428 /// write faulted and when the topic manager failed, and in both it
5429 /// records no applied row. A transaction landed when its topic is applied
5430 /// or was already applied (a dupe). Other graphs carry on, and the caller
5431 /// keeps the cursor from passing a graph that did not land.
5432 ///
5433 /// Each transaction's submit is one write section of the engine's gate
5434 /// ([`Engine::finalize_submit_gate`]) and the gate is asked between two:
5435 /// a deadline that is due stops the sequence there (this future is
5436 /// dropped at that await, between two whole transactions).
5437 async fn submit_finalized_graphs(
5438 &self,
5439 sink: &crate::gasp_overlay::FinalizedGraphSink,
5440 peer_url: &str,
5441 topic: &str,
5442 submitted: &std::cell::Cell<u64>,
5443 ) -> bool {
5444 let finalized: Vec<_> = sink
5445 .lock()
5446 .unwrap_or_else(std::sync::PoisonError::into_inner)
5447 .drain(..)
5448 .collect();
5449
5450 let mut all_landed = true;
5451 for graph in &finalized {
5452 for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5453 self.finalize_gate.stop_if_due().await;
5454 if position == 0 {
5455 submitted.set(submitted.get() + 1);
5456 }
5457 let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5458 // No deadline drops a write section, and the section does
5459 // not drop ITSELF either (bsv-low #559, the delta lens's
5460 // DELTA-3 and the lens fold of 2026-10-07, F2,
5461 // `set_finalize_submit_budget`): the submit always runs to
5462 // its end. What is bounded is each storage call and hook
5463 // inside it ([`CallBound`]): one that never answers is that
5464 // call's FAULT, the submit handles it as any fault (nothing
5465 // of the transaction held, or all of it), the transaction
5466 // did not land, its UTXO fails and the cursor stays.
5467 let outcome = {
5468 let _writing = self.finalize_gate.write_section();
5469 let bound = self
5470 .finalize_submit_budget
5471 .as_ref()
5472 .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5473 self.submit_bounded(
5474 &tagged,
5475 SubmitMode::HistoricalTxNoSpv,
5476 bound.as_ref(),
5477 true,
5478 WalkBreachPolicy::NotNow,
5479 )
5480 .await
5481 };
5482 let not_landed = match outcome {
5483 Ok((_, report)) => {
5484 let landed = report.is_durable()
5485 && (report.applied_topics.contains(&graph.topic)
5486 || report.deduped_topics.contains(&graph.topic));
5487 (!landed).then(|| {
5488 if report.is_durable() {
5489 "the topic manager failed on it".to_string()
5490 } else {
5491 format!("not durable: {}", report.summary())
5492 }
5493 })
5494 }
5495 Err(e) => Some(e.to_string()),
5496 };
5497 if let Some(why) = not_landed {
5498 warn!(
5499 "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
5500 graph.topic
5501 );
5502 all_landed = false;
5503 break;
5504 }
5505 }
5506 }
5507
5508 if !finalized.is_empty() {
5509 info!(
5510 "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
5511 finalized.len()
5512 );
5513 }
5514 all_landed
5515 }
5516
5517 /// Union local records, tracker answers, SLAP-named providers and bootstrap.
5518 /// Reference: overlay-services@480d77a src/Engine.ts:839-876 and
5519 /// ts-stack@edf6e03 packages/sdk/src/overlay-tools/LookupResolver.ts:1976.
5520 async fn discover_ship_peers(
5521 &self,
5522 topic: &str,
5523 lookup_hosts: &mut Option<Vec<String>>,
5524 ) -> Vec<String> {
5525 let mut domains = self.local_ship_peers(topic).await;
5526 if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
5527 domains.extend(bootstrap.iter().cloned());
5528 }
5529 if let Some(client) = &self.ship_lookup_client {
5530 if lookup_hosts.is_none() {
5531 *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
5532 }
5533 let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
5534 for host in lookup_hosts.as_ref().into_iter().flatten() {
5535 match client.lookup(host, &question).await {
5536 Ok(outputs) => {
5537 domains.extend(outputs.iter().filter_map(|output| {
5538 parse_lookup_advertisement(output, b"SHIP", topic)
5539 }));
5540 }
5541 Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
5542 }
5543 }
5544 }
5545 self.ship_peers_excluding_self(domains)
5546 }
5547
5548 async fn discover_ship_lookup_hosts(
5549 &self,
5550 client: &dyn crate::ship_lookup::ShipLookupClient,
5551 ) -> Vec<String> {
5552 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
5553 let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
5554 let tracker_origins: HashSet<_> = trackers
5555 .iter()
5556 .map(|host| crate::gasp::peer_origin(host))
5557 .collect();
5558 let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
5559 let mut advertised = Vec::new();
5560 for tracker in &trackers {
5561 match client.lookup(tracker, &question).await {
5562 Ok(outputs) => {
5563 advertised.extend(outputs.iter().filter_map(|output| {
5564 parse_lookup_advertisement(output, b"SLAP", "ls_ship")
5565 }));
5566 }
5567 Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
5568 }
5569 }
5570 let providers = self
5571 .ship_peers_excluding_self(advertised)
5572 .into_iter()
5573 .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
5574 .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
5575 trackers.into_iter().chain(providers).collect()
5576 }
5577
5578 fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
5579 crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
5580 self.config.hosting_url.as_ref().is_none_or(|our_url| {
5581 crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
5582 })
5583 }))
5584 }
5585
5586 /// Read local SHIP records independently of network discovery failures.
5587 async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
5588 let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
5589 warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
5590 return Vec::new();
5591 };
5592
5593 let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
5594
5595 let refs = match ship_ls.lookup(&question).await {
5596 Ok(LookupResult::OutputList(refs)) => refs,
5597 Ok(LookupResult::Answer(_)) => {
5598 error!(
5599 "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
5600 LookupAnswer; expected OutputList. Skipping."
5601 );
5602 return Vec::new();
5603 }
5604 Err(e) => {
5605 error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
5606 return Vec::new();
5607 }
5608 };
5609
5610 let mut domains = HashSet::new();
5611 for reference in &refs {
5612 if let Ok(Some(output)) = self
5613 .storage
5614 .find_output(&reference.txid, reference.output_index, None, None, false)
5615 .await
5616 {
5617 if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
5618 // Every spelling of our own origin is ourselves (the
5619 // delta-2 fold's D2-M2: `https://us/?x` passed the
5620 // string compare).
5621 if let Some(ref our_url) = self.config.hosting_url {
5622 if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
5623 || crate::gasp::peer_origin(&domain)
5624 == crate::gasp::peer_origin(our_url)
5625 {
5626 continue;
5627 }
5628 }
5629 domains.insert(domain);
5630 }
5631 }
5632 }
5633
5634 // One peer per normalized origin (bsv-low #555, the delta-2 fold's
5635 // D2-M2, restored by the delta-4 fold's D4-M1): eight spellings of
5636 // one server are one peer, the one its health row is keyed by.
5637 crate::gasp::ship_peers_by_origin(domains)
5638 }
5639
5640 // ========================================================================
5641 // Helpers
5642 // ========================================================================
5643
5644 /// Get a reference to the storage backend.
5645 pub fn storage(&self) -> &dyn Storage {
5646 self.storage.as_ref()
5647 }
5648
5649 /// Get the engine configuration.
5650 pub fn config(&self) -> &EngineConfig {
5651 &self.config
5652 }
5653}
5654
5655/// The SPV check of [`Engine::submit`], as one function over a BORROWED
5656/// tracker: the reference's `tx.verify(chainTracker)` (roots against the
5657/// tracker, every unproven input's script executed, the value rule), its
5658/// `tx.verify('scripts only')` when `chain_tracker` is `None` (roots accepted
5659/// unchecked, scripts still run), and the pre-2026-09-08 structural check when
5660/// `verify_scripts` is `false` ([`Engine::set_script_verification`]'s escape
5661/// hatch).
5662///
5663/// There is ONE verifier. `Engine::submit` calls this with its own tracker,
5664/// switch and walk budget; the GASP anchor check
5665/// (`OverlayGASPStorage::validate_graph_anchor`, bsv-low #551) calls it with
5666/// the same three, handed over by `Engine::start_gasp_sync`.
5667///
5668/// `Ok(Some(_))` is a walk that COULD NOT RUN within `budget` (bsv-low #592):
5669/// not a verdict on the transaction. The caller goes on as it would without a
5670/// walk and says so.
5671pub(crate) async fn verify_spv_like_the_reference(
5672 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5673 verify_scripts: bool,
5674 beef_bytes: &[u8],
5675 subject_txid: &str,
5676 budget: DoorBudget,
5677) -> Result<Option<WalkCouldNotRun>, EngineError> {
5678 verify_spv_trusting(
5679 chain_tracker,
5680 verify_scripts,
5681 beef_bytes,
5682 subject_txid,
5683 &HashSet::new(),
5684 budget,
5685 )
5686 .await
5687}
5688
5689/// [`verify_spv_like_the_reference`] trusting the transactions an earlier
5690/// walk of the same submit passed over (a predecessor the door lands first,
5691/// the E1D delta fold, L4): the script walk neither checks nor descends
5692/// them. The structural escape hatch checks the whole BEEF it is given.
5693async fn verify_spv_trusting(
5694 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5695 verify_scripts: bool,
5696 beef_bytes: &[u8],
5697 subject_txid: &str,
5698 trusted: &HashSet<String>,
5699 budget: DoorBudget,
5700) -> Result<Option<WalkCouldNotRun>, EngineError> {
5701 if verify_scripts {
5702 Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
5703 } else {
5704 Engine::verify_spv_structurally(chain_tracker, beef_bytes)
5705 .await
5706 .map(|()| None)
5707 }
5708}
5709
5710/// The BUMP that proves each txid of `beef`: the first BUMP whose level 0
5711/// carries it, as `Beef::find_bump` picks, built in one pass over the BUMPs'
5712/// leaves (NL-6e; `find_bump` per transaction scans them all each time).
5713fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
5714 let mut proven: HashMap<&str, usize> = HashMap::new();
5715 for (at, bump) in beef.bumps.iter().enumerate() {
5716 for leaf in bump.path.first().into_iter().flatten() {
5717 if let Some(hash) = leaf.hash.as_deref() {
5718 proven.entry(hash).or_insert(at);
5719 }
5720 }
5721 }
5722 proven
5723}
5724
5725/// The transactions the SPV walk of `from` passes over in `beef`
5726/// ([`Engine::verify_beef_linear_with`]'s traversal, nothing checked): `from`
5727/// and every source the BEEF carries, a proven transaction included and not
5728/// descended. The E1D delta fold, L4: what a landing need not walk again.
5729fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
5730 let bodies: HashMap<String, &Transaction> = beef
5731 .txs
5732 .iter()
5733 .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
5734 .collect();
5735 let proven = proven_index(beef);
5736 let mut cover = HashSet::new();
5737 let mut queue = vec![from.to_string()];
5738 while let Some(txid) = queue.pop() {
5739 if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
5740 continue;
5741 }
5742 if let Some(tx) = bodies.get(&txid) {
5743 queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
5744 }
5745 }
5746 cover
5747}
5748
5749/// The engine's [`crate::gasp::FinalizedGraphHook`] (bsv-low #552): submit
5750/// what a peer's sync just finalized, before its next UTXO is asked for.
5751struct SubmitAsFinalized<'e> {
5752 engine: &'e Engine,
5753 sink: crate::gasp_overlay::FinalizedGraphSink,
5754 peer_url: &'e str,
5755 topic: &'e str,
5756 submitted: &'e std::cell::Cell<u64>,
5757}
5758
5759#[async_trait::async_trait(?Send)]
5760impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
5761 async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
5762 // A graph is counted as its first submit starts, and the deadline
5763 // cannot land inside a submit: a sync dropped in here has written at
5764 // least that transaction whole (an ancestors-first prefix).
5765 let landed = self
5766 .engine
5767 .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
5768 .await;
5769 if landed {
5770 Ok(())
5771 } else {
5772 // Fails this UTXO: the cursor stays below it and the next sync
5773 // asks for it again (the lens fold's MEDIUM-1).
5774 Err(crate::gasp::GASPError::StorageError(
5775 "a finalize submit did not land".to_string(),
5776 ))
5777 }
5778 }
5779}
5780
5781/// Result of a GASP sync operation.
5782///
5783/// Returned by `Engine::start_gasp_sync()` to summarize what happened.
5784#[derive(Debug, Clone, Serialize, Deserialize)]
5785pub struct GASPSyncResult {
5786 /// Per-topic sync results.
5787 pub topics_synced: HashMap<String, TopicSyncResult>,
5788}
5789
5790/// Sync result for a single topic.
5791#[derive(Debug, Clone, Serialize, Deserialize)]
5792pub struct TopicSyncResult {
5793 /// Peer URLs discovered or configured for this topic.
5794 pub peers: Vec<String>,
5795 /// How peers were determined: "ship" or "peers".
5796 pub sync_type: String,
5797 /// Any errors encountered during sync (peer URL -> error message): a
5798 /// peer whose sync failed or ran over its budget. A single UTXO whose
5799 /// graph ingest failed is NOT listed here: it is warned (`Error ingesting
5800 /// UTXO`), held back by the cursor gap guard and asked for again by the
5801 /// next sync.
5802 pub errors: Vec<String>,
5803 /// Manager-named inputs pruned because a peer answered that it does not
5804 /// hold them (the D8 decoy rule, `GASPSync::pruned_inputs`). Counted per
5805 /// distinct outpoint PER PEER per sync and summed over this topic's
5806 /// peers: one decoy seen through two peers reads 2. Not errors: the
5807 /// graphs completed without those branches.
5808 #[serde(default)]
5809 pub pruned_inputs: u64,
5810 /// Graphs the anchor check REFUSED and discarded whole (bsv-low #551,
5811 /// `GASPSync::discarded_graphs`): a root whose BEEF does not verify, or
5812 /// one the topic manager does not admit at the end of the replay. Summed
5813 /// over this topic's peers. Not errors, and nothing of them was admitted.
5814 #[serde(default)]
5815 pub discarded_graphs: u64,
5816 /// Graphs HELD because their anchor's script walk could not run within
5817 /// the engine's walk budget (bsv-low #592; the E592 lens fold, H1 and
5818 /// L3; `GASPSync::anchor_walk_held`), summed over this topic's peers.
5819 /// Nothing of them was finalized; each UTXO stays below its cursor until
5820 /// the peer serves its root proven. Not errors and not discarded.
5821 #[serde(default)]
5822 pub anchor_walk_held_graphs: u64,
5823 /// Graphs that passed the anchor check and were handed to submit, summed
5824 /// over this topic's peers (bsv-low #552). Under a per-peer budget a
5825 /// graph is counted, and submitted, the moment it finalizes, so the
5826 /// count includes those of a peer the deadline then dropped. A counted
5827 /// graph had its first transaction submitted whole (the deadline never
5828 /// lands inside a submit); one whose later submit did not land is still
5829 /// counted, and its UTXO stays below the cursor.
5830 /// A graph is counted as its first submit STARTS; a first submit that FAULTS
5831 /// still counts here, so this is not a delivered count (bsv-low #559).
5832 #[serde(default)]
5833 pub finalized_graphs: u64,
5834 /// Graphs that were mid-walk when a peer's sync was dropped at its
5835 /// budget (bsv-low #552): at most one per peer per sync. NOTHING of such
5836 /// a graph was admitted; the next sync walks it again. The same root
5837 /// counted tick after tick with no `finalized_graphs` and no
5838 /// `cursor_moves` is ONE graph whose walk outlasts the budget (with a
5839 /// per-graph budget it is also counted in `deferred_graphs` and resumed,
5840 /// bsv-low #555).
5841 #[serde(default)]
5842 pub deadline_dropped_graphs: u64,
5843 /// Every persisted cursor that moved in this sync (bsv-low #552), one
5844 /// entry per peer. After a completed sync `to` is the peer's highest
5845 /// score seen (less the gap guard); after a deadline it is
5846 /// `GASPSync::completed_cursor`: past completed UTXOs only.
5847 #[serde(default)]
5848 pub cursor_moves: Vec<CursorMove>,
5849 /// Graphs DEFERRED in this sync, summed over peers (bsv-low #555,
5850 /// `Engine::set_graph_budget`): a new record, or a resumed graph
5851 /// deferred again. Their UTXOs are held below the cursor.
5852 #[serde(default)]
5853 pub deferred_graphs: u64,
5854 /// Graphs RESUMED from a record in this sync.
5855 #[serde(default)]
5856 pub resumed_graphs: u64,
5857 /// Resumed graphs that completed and landed in this sync (their record
5858 /// deleted).
5859 #[serde(default)]
5860 pub converged_graphs: u64,
5861 /// Records deleted without converging, with their reason.
5862 #[serde(default)]
5863 pub dropped_graphs: Vec<DroppedDeferral>,
5864 /// Walks the per-graph budget (or the per-peer deadline) cut having
5865 /// fetched NOTHING in their pass (bsv-low #555, the lens fold's H1). A
5866 /// peer's sync whose only outcome was these is a failed attempt for the
5867 /// quarantine count.
5868 #[serde(default)]
5869 pub stalled_graphs: u64,
5870 /// Records NOT resumed in this sync: the resumes of a peer's pass share
5871 /// ONE per-graph budget and had spent it (the lens fold's M1). Held for
5872 /// the next sync, untouched.
5873 #[serde(default)]
5874 pub held_back_graphs: u64,
5875}
5876
5877/// A deferred graph's record deleted without converging (bsv-low #555,
5878/// [`TopicSyncResult::dropped_graphs`]).
5879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5880pub struct DroppedDeferral {
5881 /// The peer URL.
5882 pub peer: String,
5883 /// The graph's root outpoint.
5884 pub outpoint: String,
5885 /// [`crate::gasp::DropReason::as_str`].
5886 pub reason: String,
5887}
5888
5889/// One peer's persisted `last_interaction` cursor moving `from` -> `to` in a
5890/// sync ([`TopicSyncResult::cursor_moves`]).
5891#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5892pub struct CursorMove {
5893 /// The peer URL.
5894 pub peer: String,
5895 /// The cursor the sync entered with.
5896 pub from: u64,
5897 /// The cursor it persisted.
5898 pub to: u64,
5899}
5900
5901/// Get current time in milliseconds (for output scores).
5902///
5903/// Uses `js_sys::Date::now()` on wasm32 (Cloudflare Workers) since
5904/// `std::time::SystemTime` is not available on that platform.
5905fn current_timestamp_ms() -> f64 {
5906 #[cfg(target_arch = "wasm32")]
5907 {
5908 js_sys::Date::now()
5909 }
5910 #[cfg(not(target_arch = "wasm32"))]
5911 {
5912 std::time::SystemTime::now()
5913 .duration_since(std::time::UNIX_EPOCH)
5914 .map_or(0.0, |d| d.as_millis() as f64)
5915 }
5916}
5917
5918/// Extract the domain field from a SHIP PushDrop locking script.
5919///
5920/// SHIP PushDrop format: field[0] = "SHIP", field[1] = identity_key,
5921/// field[2] = domain (UTF-8), field[3] = topic.
5922/// Returns `None` if the script cannot be parsed or is not a SHIP advertisement.
5923fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
5924 parse_advertisement_domain_from_script(output_script, b"SHIP", None)
5925}
5926
5927fn parse_lookup_advertisement(
5928 output: &OutputListItem,
5929 protocol: &[u8],
5930 scope: &str,
5931) -> Option<String> {
5932 let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
5933 .ok()?;
5934 let script = tx
5935 .outputs
5936 .get(output.output_index as usize)?
5937 .locking_script
5938 .to_binary();
5939 parse_advertisement_domain_from_script(&script, protocol, Some(scope))
5940}
5941
5942fn parse_advertisement_domain_from_script(
5943 output_script: &[u8],
5944 protocol: &[u8],
5945 scope: Option<&str>,
5946) -> Option<String> {
5947 use bsv_rs::script::templates::PushDrop;
5948
5949 let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
5950 let pushdrop = PushDrop::decode(&script.into()).ok()?;
5951
5952 if pushdrop.fields.len() < 3 {
5953 return None;
5954 }
5955
5956 if pushdrop.fields[0] != protocol {
5957 return None;
5958 }
5959
5960 if scope.is_some_and(|expected| {
5961 pushdrop
5962 .fields
5963 .get(3)
5964 .is_none_or(|actual| actual != expected.as_bytes())
5965 }) {
5966 return None;
5967 }
5968
5969 let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
5970 if domain.is_empty() {
5971 return None;
5972 }
5973
5974 Some(domain)
5975}
5976
5977// ============================================================================
5978// Submit-time spend verification helpers (reference parity, 2026-09-08)
5979// ============================================================================
5980
5981// ============================================================================
5982// Error type
5983// ============================================================================
5984
5985#[derive(Debug, thiserror::Error)]
5986pub enum EngineError {
5987 #[error("This server does not support this topic: {0}")]
5988 UnsupportedTopic(String),
5989
5990 #[error("Lookup service not found for provider: {0}")]
5991 LookupServiceNotFound(String),
5992
5993 /// GASP: `/requestForeignGASPNode` asked for a graph/txid we don't
5994 /// have locally. Matches mainline's 400-class "not found" response.
5995 #[error("No matching output found!")]
5996 NodeNotFound,
5997
5998 #[error("lookup failed: {0}")]
5999 LookupFailed(String),
6000
6001 /// The CALLER's query was malformed — a bad identity key, a gameId that is
6002 /// not 32 bytes of hex, a missing required field. Distinct from
6003 /// `LookupFailed`, which means WE failed: without the distinction every
6004 /// caller mistake was reported as a server fault (HTTP 500), so a malformed
6005 /// query and a genuine outage were indistinguishable in logs and alerts on
6006 /// an unauthenticated endpoint anyone can hit.
6007 #[error("invalid query: {0}")]
6008 InvalidQuery(String),
6009
6010 #[error("storage error: {0}")]
6011 StorageError(String),
6012
6013 #[error("broadcast error: {0}")]
6014 BroadcastError(String),
6015
6016 #[error("SPV verification failed: {0}")]
6017 SpvError(String),
6018
6019 #[error("BEEF parsing failed: {0}")]
6020 BeefParseError(String),
6021
6022 /// A spend in the submitted BEEF does not satisfy its source output's
6023 /// locking script: the input's unlocking script was EXECUTED (reference
6024 /// parity, 2026-09-08) and the interpreter refused it. Distinct from
6025 /// [`EngineError::SpvError`] (a bad or unverifiable PROOF, a missing
6026 /// source, a chain-tracker fault) so an operator can tell a bad spend from
6027 /// a bad proof. `input_index` is the failing input's position in the
6028 /// transaction whose script failed (the subject, or an unproven
6029 /// ancestor of it in the same BEEF); `reason` is the interpreter's own
6030 /// message.
6031 #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6032 ScriptVerificationFailed {
6033 subject_txid: String,
6034 input_index: u32,
6035 reason: String,
6036 },
6037
6038 /// The DOOR walk ([`Engine::verify_scripts_only`]) hit a STRUCTURAL fault
6039 /// before reaching a verdict on `at_txid` (a source the BEEF lacks, a
6040 /// parse fault, the value rule): not the interpreter's verdict, so not a
6041 /// refusal for a caller whose bar is the network. `subject_judged` says
6042 /// whether the subject's own inputs had all executed before the fault
6043 /// (an ancestor faulted) or not (the subject itself is unjudged).
6044 #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6045 ScriptWalkInconclusive {
6046 at_txid: String,
6047 subject_judged: bool,
6048 reason: String,
6049 },
6050
6051 /// The DOOR walk exceeded its own bound ([`DoorBudget`]: the static work
6052 /// estimate, the interpreter's memory limit tripping, or the estimate of
6053 /// the door's own memory; `limb` says which): the door's verdict, never
6054 /// the network's — the request proceeds to the network.
6055 #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6056 ScriptWalkOverBudget {
6057 at_txid: String,
6058 subject_judged: bool,
6059 limb: DoorLimb,
6060 what: String,
6061 },
6062
6063 /// The submit's own script walk COULD NOT RUN within the engine's
6064 /// [`DoorBudget`] (bsv-low #592) and nothing on record stands in for it
6065 /// ([`WalkBreachPolicy::NotNow`], every door but the queue replay of a
6066 /// gated submission; the E592 lens fold, H1). NOT NOW: nothing was
6067 /// judged, nothing written, and it is NOT a refusal either: the budget
6068 /// says nothing about the transaction. The way out is the subject
6069 /// PROVEN: a proven transaction is not walked, so an honest body too
6070 /// big to walk unmined lands once its block is mined (a client retries
6071 /// after the block, not at once).
6072 #[error(
6073 "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6074 .0.limb.as_str(),
6075 .0.what
6076 )]
6077 WalkCouldNotRun(WalkCouldNotRun),
6078
6079 #[error("{0}")]
6080 Other(String),
6081}
6082
6083impl From<StorageError> for EngineError {
6084 fn from(e: StorageError) -> Self {
6085 EngineError::StorageError(e.to_string())
6086 }
6087}
6088
6089impl From<TopicManagerError> for EngineError {
6090 fn from(e: TopicManagerError) -> Self {
6091 EngineError::Other(e.to_string())
6092 }
6093}
6094
6095impl From<LookupServiceError> for EngineError {
6096 fn from(e: LookupServiceError) -> Self {
6097 EngineError::LookupFailed(e.to_string())
6098 }
6099}
6100
6101impl From<AdvertiserError> for EngineError {
6102 fn from(e: AdvertiserError) -> Self {
6103 EngineError::Other(e.to_string())
6104 }
6105}
6106
6107// ============================================================================
6108// Tests
6109// ============================================================================
6110
6111/// Baseline sync order: configured peer topics before SHIP discovery topics,
6112/// each by name (bsv-low #555, D-M1). `start_gasp_sync` advances SHIP topics
6113/// with explicit bootstrap peers ahead of this order so their sync can run
6114/// without importing tracker records. Deferred-graph reservations still use
6115/// each topic's configured target.
6116pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6117 let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6118 order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6119 order
6120}
6121
6122#[cfg(test)]
6123mod tests {
6124 use super::*;
6125 use crate::lookup_service::LookupService as LookupServiceTrait;
6126 use crate::storage::memory::MemoryStorage;
6127 use crate::topic_manager::TopicManager as TopicManagerTrait;
6128 use async_trait::async_trait;
6129 use std::sync::Mutex;
6130
6131 // bsv-low #555, the delta fold's D-M1: the configured peers' topics are
6132 // synced before the ls_ship-discovered ones, each class by name; the map's
6133 // order was arbitrary.
6134 #[test]
6135 fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6136 let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6137 let config: SyncConfiguration = HashMap::from([
6138 ("tm_a".to_string(), SyncTarget::Ship),
6139 ("tm_z".to_string(), peers("https://z")),
6140 ("tm_b".to_string(), SyncTarget::Ship),
6141 ("tm_m".to_string(), peers("https://m")),
6142 ("tm_off".to_string(), SyncTarget::Disabled),
6143 ]);
6144 let order: Vec<&str> = sync_order(&config)
6145 .iter()
6146 .map(|(t, _)| t.as_str())
6147 .collect();
6148 assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6149 }
6150
6151 // ── Mock TopicManager ──────────────────────────────────────────────
6152
6153 struct MockTopicManager {
6154 admit_indices: Vec<u32>,
6155 }
6156
6157 impl MockTopicManager {
6158 fn admitting(indices: Vec<u32>) -> Self {
6159 Self {
6160 admit_indices: indices,
6161 }
6162 }
6163 }
6164
6165 #[async_trait(?Send)]
6166 impl TopicManagerTrait for MockTopicManager {
6167 async fn identify_admissible_outputs(
6168 &self,
6169 _tx: &Transaction,
6170 _previous_coins: &[u8],
6171 _off_chain_values: Option<&[u8]>,
6172 _mode: SubmitMode,
6173 _context: &TopicAdmittanceContext,
6174 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6175 Ok(AdmittanceInstructions {
6176 outputs_to_admit: self.admit_indices.clone(),
6177 coins_to_retain: vec![],
6178 coins_removed: None,
6179 })
6180 }
6181
6182 async fn get_documentation(&self) -> String {
6183 "Mock topic manager".to_string()
6184 }
6185
6186 async fn get_metadata(&self) -> ServiceMetadata {
6187 ServiceMetadata {
6188 name: "mock-tm".to_string(),
6189 description: Some("Mock for testing".to_string()),
6190 ..Default::default()
6191 }
6192 }
6193 }
6194
6195 // ── Mock LookupService ─────────────────────────────────────────────
6196
6197 struct MockLookupService {
6198 records: Mutex<Vec<UTXOReference>>,
6199 }
6200
6201 impl MockLookupService {
6202 fn new() -> Self {
6203 Self {
6204 records: Mutex::new(Vec::new()),
6205 }
6206 }
6207 }
6208
6209 #[async_trait(?Send)]
6210 impl LookupServiceTrait for MockLookupService {
6211 fn admission_mode(&self) -> AdmissionMode {
6212 AdmissionMode::LockingScript
6213 }
6214
6215 fn spend_notification_mode(&self) -> SpendNotificationMode {
6216 SpendNotificationMode::None
6217 }
6218
6219 async fn output_admitted_by_topic(
6220 &self,
6221 payload: &OutputAdmittedByTopic,
6222 ) -> Result<(), LookupServiceError> {
6223 let (txid, oi) = match payload {
6224 OutputAdmittedByTopic::LockingScript {
6225 txid, output_index, ..
6226 } => (txid.clone(), *output_index),
6227 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
6228 ("whole".into(), *output_index)
6229 }
6230 };
6231 self.records.lock().unwrap().push(UTXOReference {
6232 txid,
6233 output_index: oi,
6234 });
6235 Ok(())
6236 }
6237
6238 async fn output_evicted(
6239 &self,
6240 txid: &str,
6241 output_index: u32,
6242 ) -> Result<(), LookupServiceError> {
6243 self.records
6244 .lock()
6245 .unwrap()
6246 .retain(|r| !(r.txid == txid && r.output_index == output_index));
6247 Ok(())
6248 }
6249
6250 async fn lookup(
6251 &self,
6252 _question: &LookupQuestion,
6253 ) -> Result<LookupResult, LookupServiceError> {
6254 Ok(LookupResult::OutputList(
6255 self.records.lock().unwrap().clone(),
6256 ))
6257 }
6258
6259 async fn get_documentation(&self) -> String {
6260 "Mock lookup service".to_string()
6261 }
6262
6263 async fn get_metadata(&self) -> ServiceMetadata {
6264 ServiceMetadata {
6265 name: "mock-ls".to_string(),
6266 ..Default::default()
6267 }
6268 }
6269 }
6270
6271 // ── Test BEEF data ──────────────────────────────────────────────────
6272
6273 /// Real BRC-62 BEEF from TS overlay-services test suite.
6274 const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
6275 const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
6276
6277 fn decode_hex(hex: &str) -> Vec<u8> {
6278 (0..hex.len())
6279 .step_by(2)
6280 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
6281 .collect()
6282 }
6283
6284 fn test_beef() -> Vec<u8> {
6285 decode_hex(TEST_BEEF_HEX)
6286 }
6287
6288 fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
6289 TaggedBEEF::new(
6290 test_beef(),
6291 topics.into_iter().map(str::to_string).collect(),
6292 )
6293 }
6294
6295 // ── Helpers ────────────────────────────────────────────────────────
6296
6297 fn make_engine(admit_indices: Vec<u32>) -> Engine {
6298 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6299 managers.insert(
6300 "tm_test".to_string(),
6301 Box::new(MockTopicManager::admitting(admit_indices)),
6302 );
6303
6304 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
6305 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
6306
6307 let storage = Box::new(MemoryStorage::new());
6308
6309 Engine::new(
6310 managers,
6311 lookup_services,
6312 storage,
6313 None,
6314 EngineConfig::default(),
6315 )
6316 }
6317
6318 // ── Tests ──────────────────────────────────────────────────────────
6319
6320 /// bsv-low#289 parse-once + zanaadu#284 subject discipline: the tx handed
6321 /// to every topic manager must be the ATOMIC SUBJECT of the submitted
6322 /// BEEF, not whatever sits last in wire order. bsv-rs < 0.3.20's
6323 /// `from_beef(_, None)` picked `txs.last()`; wallet serializers do not all
6324 /// place the subject last, so a TM could be handed a PARENT to validate.
6325 /// This pins the engine on the fixed rule (`txid ?? atomicTxid ?? last`).
6326 #[tokio::test]
6327 async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
6328 use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
6329 use std::cell::RefCell;
6330 use std::rc::Rc;
6331
6332 // Rebuild the fixture chain SUBJECT-FIRST via to_writer (to_binary
6333 // re-sorts parents-first and would hide the trigger).
6334 let parsed = Beef::from_binary(&test_beef()).unwrap();
6335 let parent = parsed.txs[0].tx().unwrap().clone();
6336 let child = parsed.txs[1].tx().unwrap().clone();
6337 let child_id = child.id();
6338 let parent_id = parent.id();
6339 let mut beef = Beef::new();
6340 // The parent's BUMP rides along: the reference-parity walk
6341 // (2026-09-08) executes the subject's script against the parent and
6342 // then trusts the parent on its proof; without the BUMP it would
6343 // demand the parent's own ancestry ("Input 0 has no source
6344 // transaction"), which this fixture does not carry.
6345 for bump in &parsed.bumps {
6346 beef.merge_bump(bump.clone());
6347 }
6348 beef.merge_transaction(child);
6349 beef.merge_transaction(parent);
6350 let mut w = bsv_rs::primitives::encoding::Writer::new();
6351 w.write_u32_le(ATOMIC_BEEF);
6352 let mut le = hex::decode(&child_id).unwrap();
6353 le.reverse();
6354 w.write_bytes(&le);
6355 beef.to_writer(&mut w);
6356 let atomic_wire_order = w.into_bytes();
6357
6358 // A TM that records the txid it is handed.
6359 struct RecordingTm(Rc<RefCell<Option<String>>>);
6360 #[async_trait(?Send)]
6361 impl TopicManagerTrait for RecordingTm {
6362 async fn identify_admissible_outputs(
6363 &self,
6364 tx: &Transaction,
6365 _previous_coins: &[u8],
6366 _off_chain_values: Option<&[u8]>,
6367 _mode: SubmitMode,
6368 _context: &TopicAdmittanceContext,
6369 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6370 *self.0.borrow_mut() = Some(tx.id());
6371 Ok(AdmittanceInstructions {
6372 outputs_to_admit: vec![0],
6373 coins_to_retain: vec![],
6374 coins_removed: None,
6375 })
6376 }
6377 async fn get_documentation(&self) -> String {
6378 String::new()
6379 }
6380 async fn get_metadata(&self) -> ServiceMetadata {
6381 ServiceMetadata {
6382 name: "tm_test".to_string(),
6383 ..Default::default()
6384 }
6385 }
6386 }
6387
6388 let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
6389 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6390 managers.insert(
6391 "tm_test".to_string(),
6392 Box::new(RecordingTm(Rc::clone(&seen))),
6393 );
6394 let engine = Engine::new(
6395 managers,
6396 HashMap::new(),
6397 Box::new(MemoryStorage::new()),
6398 None,
6399 EngineConfig::default(),
6400 );
6401
6402 let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
6403 let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
6404
6405 let handed = seen.borrow().clone().expect("TM was not invoked");
6406 assert_eq!(
6407 handed, child_id,
6408 "TM must be handed the atomic subject, got the wire-order pick"
6409 );
6410 assert_ne!(handed, parent_id);
6411 }
6412
6413 /// #4 REGRESSION: history hydration must never DOWNGRADE provenance.
6414 ///
6415 /// `get_utxo_history` used to `Transaction::from_beef(beef, None)` and then
6416 /// `tx.to_beef(true)`, which keeps only the subject and DROPS the stored
6417 /// BUMPs. Measured on prod before the fix: `ls_low` byGameId `73de6e48…`
6418 /// returned 1,172 B / 1 tx / 1 bump (PROVEN) with no header, and 676 B /
6419 /// 1 tx / 0 bumps with `x-history-depth: 3`. A caller asking for MORE
6420 /// provenance got a BEEF no wallet can verify — silently, behind a 200.
6421 ///
6422 /// There was NO test on this function at all, which is exactly how that
6423 /// shipped. This one asserts the invariant that matters: whatever else
6424 /// hydration does, a proof that went in comes back out.
6425 #[tokio::test]
6426 async fn history_hydration_preserves_the_stored_proof() {
6427 use bsv_rs::transaction::Beef;
6428
6429 let engine = make_engine(vec![0]);
6430 let stored = test_beef();
6431 let bumps_before = Beef::from_binary(&stored)
6432 .expect("fixture BEEF parses")
6433 .bumps
6434 .len();
6435 assert!(
6436 bumps_before > 0,
6437 "fixture must actually carry a proof, or this cell proves nothing"
6438 );
6439
6440 let output = Output {
6441 txid: TEST_TXID.to_string(),
6442 output_index: 0,
6443 output_script: vec![0x76, 0xa9],
6444 satoshis: 1000,
6445 topic: "tm_test".to_string(),
6446 spent: false,
6447 // No admitted parents: hydration has nothing to add, so anything
6448 // it REMOVES is pure loss — the cleanest form of the bug.
6449 outputs_consumed: vec![],
6450 consumed_by: vec![],
6451 beef: Some(stored.clone()),
6452 block_height: None,
6453 score: Some(1000.0),
6454 };
6455
6456 let hydrated = engine
6457 .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
6458 .await
6459 .expect("hydration must not error")
6460 .expect("hydration must return the output");
6461 let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
6462 .expect("hydrated BEEF parses");
6463
6464 assert_eq!(
6465 beef.bumps.len(),
6466 bumps_before,
6467 "asking for history must not strip the merkle proof (issue #4)"
6468 );
6469 }
6470
6471 #[tokio::test]
6472 async fn test_submit_unsupported_topic_errors() {
6473 let engine = make_engine(vec![0]);
6474 let beef = test_tagged_beef(vec!["tm_nonexistent"]);
6475
6476 let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
6477 assert!(result.is_err());
6478 assert!(matches!(
6479 result.unwrap_err(),
6480 EngineError::UnsupportedTopic(_)
6481 ));
6482 }
6483
6484 #[tokio::test]
6485 async fn test_submit_admits_outputs() {
6486 let engine = make_engine(vec![0, 1]);
6487 let beef = test_tagged_beef(vec!["tm_test"]);
6488
6489 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6490
6491 // STEAK should have tm_test with outputs 0 and 1 admitted
6492 let instructions = steak.get("tm_test").unwrap();
6493 assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
6494
6495 // Outputs should be in storage with real data
6496 let txid = TEST_TXID;
6497 let found = engine
6498 .storage()
6499 .find_output(txid, 0, Some("tm_test"), None, true)
6500 .await
6501 .unwrap()
6502 .expect("Output 0 should be in storage");
6503
6504 assert_eq!(found.txid, TEST_TXID);
6505 assert_eq!(found.satoshis, 26172); // Real value from BRC62 BEEF
6506 assert!(
6507 !found.output_script.is_empty(),
6508 "Script should be populated"
6509 );
6510 assert!(found.beef.is_some(), "BEEF should be stored");
6511 assert!(!found.spent);
6512 }
6513
6514 #[tokio::test]
6515 async fn test_submit_duplicate_is_skipped() {
6516 let engine = make_engine(vec![0]);
6517 let beef = test_tagged_beef(vec!["tm_test"]);
6518
6519 // First submit
6520 let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6521 assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
6522
6523 // Second submit — should be a dupe, no new outputs admitted
6524 let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6525 assert!(steak2["tm_test"].outputs_to_admit.is_empty());
6526 }
6527
6528 #[tokio::test]
6529 async fn test_submit_notifies_lookup_service() {
6530 let engine = make_engine(vec![0]);
6531 let beef = test_tagged_beef(vec!["tm_test"]);
6532
6533 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6534
6535 // Lookup service should have the output
6536 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6537 let answer = engine.lookup(&question, None).await.unwrap();
6538
6539 match answer {
6540 LookupAnswer::OutputList { outputs } => {
6541 assert_eq!(outputs.len(), 1);
6542 }
6543 _ => panic!("Expected OutputList"),
6544 }
6545 }
6546
6547 /// #289: `lookup_with_txids` returns the storage txid per hydrated
6548 /// output, aligned with the OutputList — the aggregated serializer
6549 /// writes it instead of re-hashing the BEEF.
6550 #[tokio::test]
6551 async fn test_lookup_with_txids_aligns_txids_with_outputs() {
6552 let engine = make_engine(vec![0]);
6553 let beef = test_tagged_beef(vec!["tm_test"]);
6554 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6555
6556 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6557 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
6558 match answer {
6559 LookupAnswer::OutputList { outputs } => {
6560 assert_eq!(outputs.len(), 1);
6561 assert_eq!(txids.len(), outputs.len(), "one txid per output");
6562 assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
6563 }
6564 _ => panic!("Expected OutputList"),
6565 }
6566 }
6567
6568 /// #291: the public sync-response page size is always bounded.
6569 #[test]
6570 fn sync_limit_clamp() {
6571 assert_eq!(
6572 Engine::clamp_sync_limit(None),
6573 Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
6574 "absent limit gets the default page, never unbounded"
6575 );
6576 assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
6577 assert_eq!(
6578 Engine::clamp_sync_limit(Some(u64::MAX)),
6579 Engine::SYNC_RESPONSE_MAX_LIMIT,
6580 "a huge caller-supplied limit is capped"
6581 );
6582 }
6583
6584 #[tokio::test]
6585 async fn test_lookup_unknown_service_errors() {
6586 let engine = make_engine(vec![]);
6587 let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
6588
6589 let result = engine.lookup(&question, None).await;
6590 assert!(result.is_err());
6591 assert!(matches!(
6592 result.unwrap_err(),
6593 EngineError::LookupServiceNotFound(_)
6594 ));
6595 }
6596
6597 #[tokio::test]
6598 async fn test_list_topic_managers() {
6599 let engine = make_engine(vec![]);
6600 let managers = engine.list_topic_managers().await;
6601
6602 assert_eq!(managers.len(), 1);
6603 assert!(managers.contains_key("tm_test"));
6604 assert_eq!(managers["tm_test"].name, "mock-tm");
6605 }
6606
6607 #[tokio::test]
6608 async fn test_list_lookup_service_providers() {
6609 let engine = make_engine(vec![]);
6610 let services = engine.list_lookup_service_providers().await;
6611
6612 assert_eq!(services.len(), 1);
6613 assert!(services.contains_key("ls_test"));
6614 assert_eq!(services["ls_test"].name, "mock-ls");
6615 }
6616
6617 #[tokio::test]
6618 async fn test_get_documentation() {
6619 let engine = make_engine(vec![]);
6620
6621 let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
6622 assert_eq!(tm_docs, "Mock topic manager");
6623
6624 let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
6625 assert_eq!(ls_docs, "Mock lookup service");
6626
6627 let missing = engine
6628 .get_documentation_for_topic_manager("tm_missing")
6629 .await;
6630 assert_eq!(missing, "No documentation found!");
6631 }
6632
6633 #[tokio::test]
6634 async fn test_provide_foreign_sync_response() {
6635 let engine = make_engine(vec![0]);
6636
6637 // Submit data
6638 let beef = test_tagged_beef(vec!["tm_test"]);
6639 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6640
6641 // Query GASP sync
6642 let request = GASPInitialRequest {
6643 version: 1,
6644 since: 0,
6645 limit: Some(100),
6646 };
6647 let response = engine
6648 .provide_foreign_sync_response(&request, "tm_test")
6649 .await
6650 .unwrap();
6651
6652 // One tx admitted with output index 0
6653 assert_eq!(response.utxo_list.len(), 1);
6654 assert_eq!(response.utxo_list[0].txid, TEST_TXID);
6655 assert_eq!(response.since, 0);
6656 }
6657
6658 #[tokio::test]
6659 async fn test_delete_utxo_deep_simple() {
6660 let engine = make_engine(vec![0]);
6661 let beef = test_tagged_beef(vec!["tm_test"]);
6662
6663 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6664
6665 let txid = TEST_TXID;
6666 let output = engine
6667 .storage()
6668 .find_output(txid, 0, Some("tm_test"), None, false)
6669 .await
6670 .unwrap()
6671 .unwrap();
6672
6673 // Delete it
6674 engine.delete_utxo_deep(&output, None).await.unwrap();
6675
6676 // Should be gone
6677 let found = engine
6678 .storage()
6679 .find_output(txid, 0, Some("tm_test"), None, false)
6680 .await
6681 .unwrap();
6682 assert!(found.is_none());
6683 }
6684
6685 #[tokio::test]
6686 async fn test_sync_config_defaults_to_ship() {
6687 let engine = make_engine(vec![]);
6688 let sync = &engine.config.sync_configuration;
6689
6690 // tm_test should default to SHIP sync
6691 assert!(sync.contains_key("tm_test"));
6692 assert!(matches!(sync["tm_test"], SyncTarget::Ship));
6693 }
6694
6695 #[tokio::test]
6696 async fn test_multiple_topics_in_single_submit() {
6697 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6698 managers.insert(
6699 "tm_alpha".to_string(),
6700 Box::new(MockTopicManager::admitting(vec![0])),
6701 );
6702 managers.insert(
6703 "tm_beta".to_string(),
6704 Box::new(MockTopicManager::admitting(vec![1])),
6705 );
6706
6707 let engine = Engine::new(
6708 managers,
6709 HashMap::new(),
6710 Box::new(MemoryStorage::new()),
6711 None,
6712 EngineConfig::default(),
6713 );
6714
6715 let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
6716
6717 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6718
6719 assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
6720 assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
6721 }
6722
6723 #[tokio::test]
6724 async fn test_evict_output_with_topic() {
6725 let engine = make_engine(vec![0]);
6726 let beef = test_tagged_beef(vec!["tm_test"]);
6727 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6728
6729 // Output should exist
6730 let found = engine
6731 .storage()
6732 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
6733 .await
6734 .unwrap();
6735 assert!(found.is_some(), "Output should exist before eviction");
6736
6737 // Evict with topic
6738 engine
6739 .evict_output(TEST_TXID, 0, Some("tm_test"))
6740 .await
6741 .unwrap();
6742
6743 // Output should be gone from storage
6744 let found = engine
6745 .storage()
6746 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
6747 .await
6748 .unwrap();
6749 assert!(found.is_none(), "Output should be gone after eviction");
6750
6751 // Lookup service should also have been notified (output evicted)
6752 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6753 let answer = engine.lookup(&question, None).await.unwrap();
6754 match answer {
6755 LookupAnswer::OutputList { outputs } => {
6756 assert!(
6757 outputs.is_empty(),
6758 "Lookup should return empty after eviction"
6759 );
6760 }
6761 _ => panic!("Expected OutputList"),
6762 }
6763 }
6764
6765 #[tokio::test]
6766 async fn test_evict_output_without_topic() {
6767 let engine = make_engine(vec![0]);
6768 let beef = test_tagged_beef(vec!["tm_test"]);
6769 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6770
6771 // Evict without topic — should find and remove across all topics
6772 engine.evict_output(TEST_TXID, 0, None).await.unwrap();
6773
6774 let found = engine
6775 .storage()
6776 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
6777 .await
6778 .unwrap();
6779 assert!(
6780 found.is_none(),
6781 "Output should be gone after topic-less eviction"
6782 );
6783 }
6784
6785 #[tokio::test]
6786 async fn test_evict_nonexistent_output_is_ok() {
6787 let engine = make_engine(vec![]);
6788
6789 // Evicting something that doesn't exist should not error
6790 let result = engine
6791 .evict_output("nonexistent_txid", 99, Some("tm_test"))
6792 .await;
6793 assert!(result.is_ok(), "Evicting nonexistent output should succeed");
6794 }
6795
6796 // ── Mock Broadcaster ──────────────────────────────────────────────
6797
6798 use crate::broadcaster::Broadcaster;
6799 use std::sync::Arc;
6800
6801 type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
6802
6803 struct MockBroadcaster {
6804 calls: BroadcastCallLog,
6805 }
6806
6807 impl MockBroadcaster {
6808 fn new() -> Self {
6809 Self {
6810 calls: Arc::new(Mutex::new(Vec::new())),
6811 }
6812 }
6813
6814 #[allow(dead_code, reason = "kept for future test extension")]
6815 fn call_count(&self) -> usize {
6816 self.calls.lock().unwrap().len()
6817 }
6818
6819 #[allow(dead_code, reason = "kept for future test extension")]
6820 fn calls(&self) -> Vec<(String, Vec<String>)> {
6821 self.calls.lock().unwrap().clone()
6822 }
6823 }
6824
6825 #[async_trait(?Send)]
6826 impl Broadcaster for MockBroadcaster {
6827 async fn broadcast_to_host(
6828 &self,
6829 host_url: &str,
6830 tagged_beef: &TaggedBEEF,
6831 ) -> Result<(), String> {
6832 self.calls
6833 .lock()
6834 .unwrap()
6835 .push((host_url.to_string(), tagged_beef.topics.clone()));
6836 Ok(())
6837 }
6838 }
6839
6840 // ── Mock SHIP LookupService ─────────────────────────────────────
6841
6842 /// A mock "ls_ship" lookup service that returns pre-configured UTXOReferences
6843 /// when queried. Used to test SHIP propagation without depending on
6844 /// overlay-discovery in the engine crate's unit tests.
6845 struct MockSHIPLookupService {
6846 /// Maps topic -> list of (txid, output_index) references
6847 records: Mutex<HashMap<String, Vec<UTXOReference>>>,
6848 }
6849
6850 impl MockSHIPLookupService {
6851 fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
6852 Self {
6853 records: Mutex::new(records),
6854 }
6855 }
6856 }
6857
6858 #[async_trait(?Send)]
6859 impl LookupServiceTrait for MockSHIPLookupService {
6860 fn admission_mode(&self) -> AdmissionMode {
6861 AdmissionMode::LockingScript
6862 }
6863
6864 fn spend_notification_mode(&self) -> SpendNotificationMode {
6865 SpendNotificationMode::None
6866 }
6867
6868 async fn output_admitted_by_topic(
6869 &self,
6870 _payload: &OutputAdmittedByTopic,
6871 ) -> Result<(), LookupServiceError> {
6872 Ok(())
6873 }
6874
6875 async fn output_evicted(
6876 &self,
6877 _txid: &str,
6878 _output_index: u32,
6879 ) -> Result<(), LookupServiceError> {
6880 Ok(())
6881 }
6882
6883 async fn lookup(
6884 &self,
6885 question: &LookupQuestion,
6886 ) -> Result<LookupResult, LookupServiceError> {
6887 // Parse the topics from the query
6888 if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
6889 let records = self.records.lock().unwrap();
6890 let mut results = Vec::new();
6891 for topic_val in topics {
6892 if let Some(topic) = topic_val.as_str() {
6893 if let Some(refs) = records.get(topic) {
6894 results.extend(refs.iter().cloned());
6895 }
6896 }
6897 }
6898 return Ok(LookupResult::OutputList(results));
6899 }
6900 Ok(LookupResult::OutputList(vec![]))
6901 }
6902
6903 async fn get_documentation(&self) -> String {
6904 "Mock SHIP lookup service".to_string()
6905 }
6906
6907 async fn get_metadata(&self) -> ServiceMetadata {
6908 ServiceMetadata {
6909 name: "mock-ls-ship".to_string(),
6910 ..Default::default()
6911 }
6912 }
6913 }
6914
6915 /// Build a minimal PushDrop locking script for a SHIP advertisement.
6916 fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
6917 use bsv_rs::script::templates::PushDrop;
6918 use bsv_rs::PublicKey;
6919
6920 let fields = vec![
6921 b"SHIP".to_vec(),
6922 vec![0x02; 33], // fake compressed pubkey bytes for identity_key field
6923 domain.as_bytes().to_vec(),
6924 topic.as_bytes().to_vec(),
6925 ];
6926
6927 // PushDrop requires a real compressed public key for the locking script
6928 // Use a well-known test key (generator point G)
6929 let pubkey = PublicKey::from_hex(
6930 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
6931 )
6932 .expect("valid test pubkey");
6933
6934 let pd = PushDrop::new(pubkey, fields);
6935 pd.lock().to_binary()
6936 }
6937
6938 /// Create engine with a broadcaster and a mock SHIP lookup service.
6939 /// The `ship_domains` parameter provides (domain, topic) pairs to pre-populate.
6940 async fn make_engine_with_broadcaster(
6941 admit_indices: Vec<u32>,
6942 broadcaster: MockBroadcaster,
6943 ship_domains: Vec<(&str, &str)>,
6944 hosting_url: Option<&str>,
6945 ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
6946 let calls = broadcaster.calls.clone();
6947
6948 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6949 managers.insert(
6950 "tm_test".to_string(),
6951 Box::new(MockTopicManager::admitting(admit_indices)),
6952 );
6953
6954 let storage = Box::new(MemoryStorage::new());
6955
6956 // Build SHIP records: map each topic to its UTXO references and
6957 // store outputs in storage with PushDrop scripts so the engine can
6958 // parse the domain when broadcasting.
6959 let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
6960
6961 for (domain, topic) in &ship_domains {
6962 // Deterministic fake txid per domain+topic
6963 let fake_txid = format!("{:064x}", {
6964 let mut h: u64 = 0;
6965 for b in domain.bytes().chain(topic.bytes()) {
6966 h = h.wrapping_mul(31).wrapping_add(u64::from(b));
6967 }
6968 h
6969 });
6970
6971 // Add to SHIP records for the mock lookup service
6972 ship_records
6973 .entry(topic.to_string())
6974 .or_default()
6975 .push(UTXOReference {
6976 txid: fake_txid.clone(),
6977 output_index: 0,
6978 });
6979
6980 // Build PushDrop SHIP script and store as an output in main storage
6981 let script = build_ship_pushdrop_script(domain, topic);
6982
6983 let output = Output {
6984 txid: fake_txid,
6985 output_index: 0,
6986 output_script: script,
6987 satoshis: 1,
6988 topic: "tm_ship".to_string(),
6989 spent: false,
6990 outputs_consumed: vec![],
6991 consumed_by: vec![],
6992 beef: None,
6993 block_height: None,
6994 score: None,
6995 };
6996
6997 storage.insert_output(&output).await.unwrap();
6998 }
6999
7000 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7001 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7002 lookup_services.insert(
7003 "ls_ship".to_string(),
7004 Box::new(MockSHIPLookupService::with_records(ship_records)),
7005 );
7006
7007 let config = EngineConfig {
7008 hosting_url: hosting_url.map(str::to_string),
7009 ..Default::default()
7010 };
7011
7012 let engine = Engine::with_chain_tracker(
7013 managers,
7014 lookup_services,
7015 storage,
7016 None,
7017 Some(Box::new(broadcaster)),
7018 None,
7019 config,
7020 );
7021
7022 (engine, calls)
7023 }
7024
7025 // ── Broadcaster Tests ─────────────────────────────────────────────
7026
7027 #[tokio::test]
7028 async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
7029 let broadcaster = MockBroadcaster::new();
7030 let (engine, calls) = make_engine_with_broadcaster(
7031 vec![0],
7032 broadcaster,
7033 vec![("https://peer1.example.com", "tm_test")],
7034 Some("https://self.example.com"),
7035 )
7036 .await;
7037
7038 let beef = test_tagged_beef(vec!["tm_test"]);
7039 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7040
7041 // Submit should succeed
7042 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7043
7044 // Broadcaster should have been called for peer1
7045 let recorded = calls.lock().unwrap();
7046 assert_eq!(
7047 recorded.len(),
7048 1,
7049 "Broadcaster should be called once for the one SHIP peer"
7050 );
7051 assert_eq!(recorded[0].0, "https://peer1.example.com");
7052 }
7053
7054 #[tokio::test]
7055 async fn test_broadcaster_not_called_on_historical_tx() {
7056 let broadcaster = MockBroadcaster::new();
7057 let (engine, calls) = make_engine_with_broadcaster(
7058 vec![0],
7059 broadcaster,
7060 vec![("https://peer1.example.com", "tm_test")],
7061 None,
7062 )
7063 .await;
7064
7065 let beef = test_tagged_beef(vec!["tm_test"]);
7066 let steak = engine
7067 .submit(&beef, SubmitMode::HistoricalTx)
7068 .await
7069 .unwrap();
7070
7071 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7072
7073 // Broadcaster should NOT have been called
7074 assert_eq!(
7075 calls.lock().unwrap().len(),
7076 0,
7077 "Broadcaster should not be called for historical TX"
7078 );
7079 }
7080
7081 #[tokio::test]
7082 async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
7083 let broadcaster = MockBroadcaster::new();
7084 let (engine, calls) = make_engine_with_broadcaster(
7085 vec![0],
7086 broadcaster,
7087 vec![("https://peer1.example.com", "tm_test")],
7088 None,
7089 )
7090 .await;
7091
7092 let beef = test_tagged_beef(vec!["tm_test"]);
7093 let steak = engine
7094 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
7095 .await
7096 .unwrap();
7097
7098 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7099
7100 assert_eq!(
7101 calls.lock().unwrap().len(),
7102 0,
7103 "Broadcaster should not be called for historical-tx-no-spv"
7104 );
7105 }
7106
7107 #[tokio::test]
7108 async fn test_broadcaster_skips_self_hosting_url() {
7109 let broadcaster = MockBroadcaster::new();
7110 let (engine, calls) = make_engine_with_broadcaster(
7111 vec![0],
7112 broadcaster,
7113 vec![
7114 ("https://self.example.com", "tm_test"),
7115 ("https://peer2.example.com", "tm_test"),
7116 ],
7117 Some("https://self.example.com"),
7118 )
7119 .await;
7120
7121 let beef = test_tagged_beef(vec!["tm_test"]);
7122 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7123
7124 // Should only broadcast to peer2, not to self
7125 let recorded = calls.lock().unwrap();
7126 assert_eq!(
7127 recorded.len(),
7128 1,
7129 "Should broadcast to peer2 only, skipping self"
7130 );
7131 assert_eq!(recorded[0].0, "https://peer2.example.com");
7132 }
7133
7134 #[tokio::test]
7135 async fn test_broadcaster_not_called_when_no_outputs_admitted() {
7136 let broadcaster = MockBroadcaster::new();
7137 // Topic manager admits nothing
7138 let (engine, calls) = make_engine_with_broadcaster(
7139 vec![],
7140 broadcaster,
7141 vec![("https://peer1.example.com", "tm_test")],
7142 None,
7143 )
7144 .await;
7145
7146 let beef = test_tagged_beef(vec!["tm_test"]);
7147 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7148
7149 assert!(steak["tm_test"].outputs_to_admit.is_empty());
7150
7151 // No outputs admitted -> no broadcast
7152 assert_eq!(
7153 calls.lock().unwrap().len(),
7154 0,
7155 "Broadcaster should not be called when no outputs are admitted"
7156 );
7157 }
7158
7159 #[tokio::test]
7160 async fn test_broadcaster_not_called_when_no_ship_peers() {
7161 let broadcaster = MockBroadcaster::new();
7162 // No SHIP peers registered
7163 let (engine, calls) = make_engine_with_broadcaster(
7164 vec![0],
7165 broadcaster,
7166 vec![], // no SHIP peers
7167 None,
7168 )
7169 .await;
7170
7171 let beef = test_tagged_beef(vec!["tm_test"]);
7172 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7173
7174 assert_eq!(
7175 calls.lock().unwrap().len(),
7176 0,
7177 "Broadcaster should not be called when no SHIP peers exist"
7178 );
7179 }
7180
7181 // ── Configurable SpendNotification mock ─────────────────────────────
7182
7183 /// A mock lookup service whose SpendNotificationMode can be configured.
7184 /// Uses a shared Arc<Mutex<Vec<OutputSpent>>> so the caller can inspect
7185 /// captured payloads after submit().
7186 struct SpendModeLookupService {
7187 mode: SpendNotificationMode,
7188 records: Mutex<Vec<UTXOReference>>,
7189 spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
7190 }
7191
7192 impl SpendModeLookupService {
7193 fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
7194 Self {
7195 mode,
7196 records: Mutex::new(Vec::new()),
7197 spent_payloads: capture,
7198 }
7199 }
7200 }
7201
7202 #[async_trait(?Send)]
7203 impl LookupServiceTrait for SpendModeLookupService {
7204 fn admission_mode(&self) -> AdmissionMode {
7205 AdmissionMode::LockingScript
7206 }
7207
7208 fn spend_notification_mode(&self) -> SpendNotificationMode {
7209 self.mode
7210 }
7211
7212 async fn output_admitted_by_topic(
7213 &self,
7214 payload: &OutputAdmittedByTopic,
7215 ) -> Result<(), LookupServiceError> {
7216 let (txid, oi) = match payload {
7217 OutputAdmittedByTopic::LockingScript {
7218 txid, output_index, ..
7219 } => (txid.clone(), *output_index),
7220 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
7221 ("whole".into(), *output_index)
7222 }
7223 };
7224 self.records.lock().unwrap().push(UTXOReference {
7225 txid,
7226 output_index: oi,
7227 });
7228 Ok(())
7229 }
7230
7231 async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
7232 self.spent_payloads.lock().unwrap().push(payload.clone());
7233 Ok(())
7234 }
7235
7236 async fn output_evicted(
7237 &self,
7238 txid: &str,
7239 output_index: u32,
7240 ) -> Result<(), LookupServiceError> {
7241 self.records
7242 .lock()
7243 .unwrap()
7244 .retain(|r| !(r.txid == txid && r.output_index == output_index));
7245 Ok(())
7246 }
7247
7248 async fn lookup(
7249 &self,
7250 _question: &LookupQuestion,
7251 ) -> Result<LookupResult, LookupServiceError> {
7252 Ok(LookupResult::OutputList(
7253 self.records.lock().unwrap().clone(),
7254 ))
7255 }
7256
7257 async fn get_documentation(&self) -> String {
7258 "SpendMode mock lookup service".to_string()
7259 }
7260
7261 async fn get_metadata(&self) -> ServiceMetadata {
7262 ServiceMetadata {
7263 name: "spend-mode-ls".to_string(),
7264 ..Default::default()
7265 }
7266 }
7267 }
7268
7269 /// TXID of the input's source transaction in TEST_BEEF_HEX (the "previous coin").
7270 const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
7271
7272 /// Build an engine with a pre-populated previous output and a SpendModeLookupService.
7273 /// Returns the engine and the shared capture vec for inspecting OutputSpent payloads.
7274 async fn make_spend_mode_engine(
7275 mode: SpendNotificationMode,
7276 ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
7277 let storage = MemoryStorage::new();
7278
7279 // Pre-populate storage with the previous output that TEST_BEEF_HEX's input spends.
7280 let prev_output = Output {
7281 txid: PREVIOUS_TXID.to_string(),
7282 output_index: 0,
7283 output_script: vec![0x76, 0xa9],
7284 satoshis: 26174,
7285 topic: "tm_test".to_string(),
7286 spent: false,
7287 outputs_consumed: vec![],
7288 consumed_by: vec![],
7289 beef: Some(test_beef()),
7290 block_height: None,
7291 score: Some(1000.0),
7292 };
7293 storage.insert_output(&prev_output).await.unwrap();
7294
7295 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7296 managers.insert(
7297 "tm_test".to_string(),
7298 Box::new(MockTopicManager::admitting(vec![0])),
7299 );
7300
7301 let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
7302 let ls = SpendModeLookupService::with_mode(mode, capture.clone());
7303 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7304 lookup_services.insert("ls_test".to_string(), Box::new(ls));
7305
7306 let engine = Engine::new(
7307 managers,
7308 lookup_services,
7309 Box::new(storage),
7310 None,
7311 EngineConfig::default(),
7312 );
7313
7314 (engine, capture)
7315 }
7316
7317 #[tokio::test]
7318 async fn test_spend_notification_mode_script() {
7319 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
7320
7321 let beef = test_tagged_beef(vec!["tm_test"]);
7322 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7323
7324 // The new output should be admitted
7325 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7326
7327 // The lookup service should have received a Script spend notification
7328 let payloads = capture.lock().unwrap().clone();
7329 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7330
7331 match &payloads[0] {
7332 OutputSpent::Script {
7333 txid,
7334 output_index,
7335 topic,
7336 spending_txid,
7337 input_index,
7338 unlocking_script,
7339 sequence_number,
7340 ..
7341 } => {
7342 assert_eq!(txid, PREVIOUS_TXID);
7343 assert_eq!(*output_index, 0);
7344 assert_eq!(topic, "tm_test");
7345 assert_eq!(spending_txid, TEST_TXID);
7346 // The BEEF has a single input at index 0 that spends PREVIOUS_TXID:0
7347 assert_eq!(*input_index, 0);
7348 // The unlocking script should be non-empty (it's a P2PKH scriptSig)
7349 assert!(
7350 !unlocking_script.is_empty(),
7351 "Unlocking script should be non-empty"
7352 );
7353 // Standard final sequence number
7354 assert_eq!(*sequence_number, 0xffff_ffff);
7355 }
7356 other => panic!("Expected OutputSpent::Script, got: {other:?}"),
7357 }
7358 }
7359
7360 #[tokio::test]
7361 async fn test_spend_notification_mode_whole_tx() {
7362 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
7363
7364 let beef = test_tagged_beef(vec!["tm_test"]);
7365 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7366
7367 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7368
7369 let payloads = capture.lock().unwrap().clone();
7370 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7371
7372 match &payloads[0] {
7373 OutputSpent::WholeTx {
7374 txid,
7375 output_index,
7376 topic,
7377 spending_atomic_beef,
7378 ..
7379 } => {
7380 assert_eq!(txid, PREVIOUS_TXID);
7381 assert_eq!(*output_index, 0);
7382 assert_eq!(topic, "tm_test");
7383 // The spending BEEF is the entire BEEF we submitted, NAMED
7384 // (atomic prefix) for the lookup service's own re-parse.
7385 let mut named =
7386 bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
7387 assert!(named.is_atomic());
7388 let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
7389 assert_eq!(
7390 named.txs.len(),
7391 submitted.txs.len(),
7392 "the whole body, never pruned"
7393 );
7394 let tip = crate::subject::subject_txid_of(&mut named).unwrap();
7395 assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
7396 }
7397 other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
7398 }
7399 }
7400
7401 #[tokio::test]
7402 async fn test_spend_notification_mode_txid() {
7403 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
7404
7405 let beef = test_tagged_beef(vec!["tm_test"]);
7406 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7407
7408 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7409
7410 let payloads = capture.lock().unwrap().clone();
7411 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7412
7413 match &payloads[0] {
7414 OutputSpent::Txid {
7415 txid,
7416 output_index,
7417 topic,
7418 spending_txid,
7419 } => {
7420 assert_eq!(txid, PREVIOUS_TXID);
7421 assert_eq!(*output_index, 0);
7422 assert_eq!(topic, "tm_test");
7423 assert_eq!(spending_txid, TEST_TXID);
7424 }
7425 other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
7426 }
7427 }
7428
7429 #[tokio::test]
7430 async fn test_spend_notification_mode_none() {
7431 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
7432
7433 let beef = test_tagged_beef(vec!["tm_test"]);
7434 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7435
7436 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7437
7438 let payloads = capture.lock().unwrap().clone();
7439 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7440
7441 match &payloads[0] {
7442 OutputSpent::None {
7443 txid,
7444 output_index,
7445 topic,
7446 } => {
7447 assert_eq!(txid, PREVIOUS_TXID);
7448 assert_eq!(*output_index, 0);
7449 assert_eq!(topic, "tm_test");
7450 }
7451 other => panic!("Expected OutputSpent::None, got: {other:?}"),
7452 }
7453 }
7454
7455 // ── GASP Sync Tests ───────────────────────────────────────────────
7456
7457 #[tokio::test]
7458 async fn test_start_gasp_sync_empty_config() {
7459 let engine = Engine::new(
7460 HashMap::new(),
7461 HashMap::new(),
7462 Box::new(MemoryStorage::new()),
7463 None,
7464 EngineConfig {
7465 sync_configuration: HashMap::new(),
7466 ..Default::default()
7467 },
7468 );
7469
7470 let result = engine.start_gasp_sync().await.unwrap();
7471 assert!(
7472 result.topics_synced.is_empty(),
7473 "No topics should be synced with empty config"
7474 );
7475 }
7476
7477 #[tokio::test]
7478 async fn test_start_gasp_sync_disabled_topic_skipped() {
7479 let mut sync_config: SyncConfiguration = HashMap::new();
7480 sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
7481
7482 let engine = Engine::new(
7483 HashMap::new(),
7484 HashMap::new(),
7485 Box::new(MemoryStorage::new()),
7486 None,
7487 EngineConfig {
7488 sync_configuration: sync_config,
7489 ..Default::default()
7490 },
7491 );
7492
7493 let result = engine.start_gasp_sync().await.unwrap();
7494 assert!(
7495 result.topics_synced.is_empty(),
7496 "Disabled topics should not appear in results"
7497 );
7498 }
7499
7500 #[tokio::test]
7501 async fn test_start_gasp_sync_peers_config() {
7502 let mut sync_config: SyncConfiguration = HashMap::new();
7503 sync_config.insert(
7504 "tm_test".to_string(),
7505 SyncTarget::Peers(vec![
7506 "https://peer1.example.com".to_string(),
7507 "https://peer2.example.com".to_string(),
7508 ]),
7509 );
7510
7511 let engine = Engine::new(
7512 HashMap::new(),
7513 HashMap::new(),
7514 Box::new(MemoryStorage::new()),
7515 None,
7516 EngineConfig {
7517 sync_configuration: sync_config,
7518 ..Default::default()
7519 },
7520 );
7521
7522 let result = engine.start_gasp_sync().await.unwrap();
7523 assert_eq!(result.topics_synced.len(), 1);
7524
7525 let topic_result = &result.topics_synced["tm_test"];
7526 assert_eq!(topic_result.peers.len(), 2);
7527 assert_eq!(topic_result.sync_type, "peers");
7528 assert!(topic_result.errors.is_empty());
7529 }
7530
7531 #[tokio::test]
7532 async fn test_start_gasp_sync_peers_filters_self() {
7533 let mut sync_config: SyncConfiguration = HashMap::new();
7534 sync_config.insert(
7535 "tm_test".to_string(),
7536 SyncTarget::Peers(vec![
7537 "https://self.example.com".to_string(),
7538 "https://peer2.example.com".to_string(),
7539 ]),
7540 );
7541
7542 let engine = Engine::new(
7543 HashMap::new(),
7544 HashMap::new(),
7545 Box::new(MemoryStorage::new()),
7546 None,
7547 EngineConfig {
7548 hosting_url: Some("https://self.example.com".to_string()),
7549 sync_configuration: sync_config,
7550 ..Default::default()
7551 },
7552 );
7553
7554 let result = engine.start_gasp_sync().await.unwrap();
7555 let topic_result = &result.topics_synced["tm_test"];
7556 assert_eq!(
7557 topic_result.peers.len(),
7558 1,
7559 "Self URL should be filtered out"
7560 );
7561 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
7562 }
7563
7564 #[derive(Clone, Default)]
7565 struct MockShipLookupClient {
7566 records: HashMap<(String, String), Vec<OutputListItem>>,
7567 failures: HashSet<String>,
7568 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
7569 }
7570
7571 struct RecordingDiscoverySyncFactory {
7572 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
7573 }
7574
7575 impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
7576 fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
7577 self.calls
7578 .borrow_mut()
7579 .push((peer.to_string(), topic.to_string()));
7580 Box::new(MockSyncRemote { utxos: Vec::new() })
7581 }
7582 }
7583
7584 #[async_trait(?Send)]
7585 impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
7586 async fn lookup(
7587 &self,
7588 host: &str,
7589 question: &LookupQuestion,
7590 ) -> Result<Vec<OutputListItem>, String> {
7591 self.calls
7592 .borrow_mut()
7593 .push((host.to_string(), question.clone()));
7594 if self.failures.contains(host) {
7595 return Err("mock tracker unavailable".to_string());
7596 }
7597 Ok(self
7598 .records
7599 .get(&(host.to_string(), question.service.clone()))
7600 .cloned()
7601 .unwrap_or_default())
7602 }
7603 }
7604
7605 fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
7606 use bsv_rs::script::templates::PushDrop;
7607 let pubkey = bsv_rs::PublicKey::from_hex(
7608 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7609 )
7610 .unwrap();
7611 let script = PushDrop::new(
7612 pubkey,
7613 vec![
7614 protocol.as_bytes().to_vec(),
7615 vec![0x02; 33],
7616 domain.as_bytes().to_vec(),
7617 scope.as_bytes().to_vec(),
7618 ],
7619 )
7620 .lock();
7621 let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
7622 tx.outputs[0].locking_script = script;
7623 tx.merkle_path = None;
7624 tx.invalidate_caches();
7625 let beef = tx.to_atomic_beef(true).unwrap();
7626 let parsed =
7627 beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
7628 .unwrap();
7629 assert_eq!(
7630 parsed.outputs[0].locking_script,
7631 tx.outputs[0].locking_script
7632 );
7633 OutputListItem {
7634 beef,
7635 output_index: 0,
7636 context: None,
7637 }
7638 }
7639
7640 #[tokio::test]
7641 async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
7642 let (mut engine, _) = make_engine_with_broadcaster(
7643 vec![0],
7644 MockBroadcaster::new(),
7645 vec![
7646 ("https://local.example", "tm_test"),
7647 ("https://shared.example/?local", "tm_test"),
7648 ],
7649 Some("https://self.example"),
7650 )
7651 .await;
7652 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
7653 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
7654 let mut client = MockShipLookupClient::default();
7655 client.records.insert(
7656 ("https://tracker.example".to_string(), "ls_ship".to_string()),
7657 vec![
7658 discovery_output("SHIP", "https://direct.example", "tm_test"),
7659 discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
7660 discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
7661 discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
7662 discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
7663 OutputListItem {
7664 beef: vec![1, 2, 3],
7665 output_index: 0,
7666 context: None,
7667 },
7668 ],
7669 );
7670 client.records.insert(
7671 ("https://tracker.example".to_string(), "ls_slap".to_string()),
7672 vec![
7673 discovery_output("SLAP", "https://lookup.example", "ls_ship"),
7674 discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
7675 discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
7676 discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
7677 discovery_output("SLAP", "https://other-service.example", "ls_other"),
7678 ],
7679 );
7680 client.records.insert(
7681 ("https://lookup.example".to_string(), "ls_ship".to_string()),
7682 vec![
7683 discovery_output("SHIP", "https://indirect.example", "tm_test"),
7684 discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
7685 ],
7686 );
7687 let calls = client.calls.clone();
7688 engine.set_ship_lookup_client(Box::new(client));
7689 let result = engine.start_gasp_sync().await.unwrap();
7690 assert_eq!(
7691 result.topics_synced["tm_test"].peers,
7692 vec![
7693 "https://bootstrap.example",
7694 "https://direct.example",
7695 "https://indirect.example",
7696 "https://local.example",
7697 "https://shared.example",
7698 ]
7699 );
7700 let calls = calls.borrow();
7701 assert_eq!(calls.len(), 3);
7702 for (_, question) in calls.iter() {
7703 let expected = if question.service == "ls_slap" {
7704 serde_json::json!({"service": "ls_ship"})
7705 } else {
7706 serde_json::json!({"topics": ["tm_test"]})
7707 };
7708 assert_eq!(question.query, expected);
7709 }
7710 }
7711
7712 #[tokio::test]
7713 async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
7714 let (mut engine, _) =
7715 make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
7716 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
7717 let mut client = MockShipLookupClient::default();
7718 let outputs = (0..9)
7719 .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
7720 .collect();
7721 client.records.insert(
7722 ("https://tracker.example".to_string(), "ls_ship".to_string()),
7723 outputs,
7724 );
7725 engine.set_ship_lookup_client(Box::new(client));
7726 let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
7727 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
7728 calls: sync_calls.clone(),
7729 }));
7730 let result = engine.start_gasp_sync().await.unwrap();
7731 let topic = &result.topics_synced["tm_test"];
7732 assert_eq!(topic.sync_type, "ship");
7733 assert_eq!(
7734 topic.peers.len(),
7735 9,
7736 "the lookup-host cap must not truncate sync peers"
7737 );
7738 assert!(topic.errors.is_empty());
7739 assert_eq!(sync_calls.borrow().len(), 9);
7740 for peer in &topic.peers {
7741 assert!(sync_calls
7742 .borrow()
7743 .contains(&(peer.clone(), "tm_test".to_string())));
7744 }
7745 }
7746
7747 #[tokio::test]
7748 async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
7749 let mut engine = make_engine(vec![]);
7750 engine.config.slap_trackers = vec![
7751 "https://dead.example".to_string(),
7752 "https://live.example".to_string(),
7753 ];
7754 let mut client = MockShipLookupClient::default();
7755 client.failures.insert("https://dead.example".to_string());
7756 client.records.insert(
7757 ("https://live.example".to_string(), "ls_ship".to_string()),
7758 vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
7759 );
7760 engine.set_ship_lookup_client(Box::new(client));
7761 let result = engine.start_gasp_sync().await.unwrap();
7762 assert_eq!(
7763 result.topics_synced["tm_test"].peers,
7764 vec!["https://peer.example"]
7765 );
7766 }
7767
7768 #[tokio::test]
7769 async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
7770 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
7771 let mut engine = make_engine(vec![]);
7772 engine
7773 .config
7774 .sync_configuration
7775 .insert("tm_other".to_string(), SyncTarget::Ship);
7776 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
7777 let mut client = MockShipLookupClient::default();
7778 let mut providers = Vec::new();
7779 for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
7780 let host = format!("https://lookup{n}.example");
7781 providers.push(discovery_output("SLAP", &host, "ls_ship"));
7782 client.records.insert(
7783 (host, "ls_ship".to_string()),
7784 vec![
7785 discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
7786 discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
7787 ],
7788 );
7789 }
7790 client.records.insert(
7791 ("https://tracker.example".to_string(), "ls_slap".to_string()),
7792 providers,
7793 );
7794 let calls = client.calls.clone();
7795 engine.set_ship_lookup_client(Box::new(client));
7796 let result = engine.start_gasp_sync().await.unwrap();
7797 assert_eq!(
7798 result.topics_synced["tm_test"].peers.len(),
7799 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
7800 );
7801 assert_eq!(
7802 result.topics_synced["tm_other"].peers.len(),
7803 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
7804 );
7805 let calls = calls.borrow();
7806 assert_eq!(
7807 calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
7808 1
7809 );
7810 let providers: HashSet<_> = calls
7811 .iter()
7812 .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
7813 .map(|(h, _)| h)
7814 .collect();
7815 assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
7816 assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
7817 }
7818
7819 #[tokio::test]
7820 async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
7821 let mut engine = make_engine(vec![]);
7822 engine.config.hosting_url = Some("https://self.example".to_string());
7823 engine.config.slap_trackers = vec!["https://dead.example".to_string()];
7824 let mut client = MockShipLookupClient::default();
7825 client.failures.insert("https://dead.example".to_string());
7826 engine.set_ship_lookup_client(Box::new(client));
7827 engine.set_ship_bootstrap_peers(
7828 "tm_test",
7829 vec![
7830 "https://bootstrap.example".to_string(),
7831 "https://BOOTSTRAP.example.:443/?x".to_string(),
7832 "http://SELF.example.:8443/path".to_string(),
7833 ],
7834 );
7835 let result = engine.start_gasp_sync().await.unwrap();
7836 assert_eq!(
7837 result.topics_synced["tm_test"].peers,
7838 vec!["https://bootstrap.example"]
7839 );
7840 }
7841
7842 #[tokio::test]
7843 async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
7844 let mut engine = make_engine(vec![]);
7845 for topic in ["tm_ship", "tm_slap"] {
7846 engine.config.sync_configuration.insert(
7847 topic.to_string(),
7848 SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
7849 );
7850 }
7851 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
7852 let mut client = MockShipLookupClient::default();
7853 client.records.insert(
7854 ("https://tracker.example".to_string(), "ls_ship".to_string()),
7855 vec![discovery_output(
7856 "SHIP",
7857 "https://discovered.example",
7858 "tm_test",
7859 )],
7860 );
7861 let lookups = client.calls.clone();
7862 engine.set_ship_lookup_client(Box::new(client));
7863 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
7864 let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
7865 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
7866 calls: calls.clone(),
7867 }));
7868 engine.start_gasp_sync().await.unwrap();
7869 assert_eq!(
7870 calls
7871 .borrow()
7872 .iter()
7873 .map(|(_, topic)| topic.as_str())
7874 .collect::<Vec<_>>(),
7875 vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
7876 );
7877 assert_eq!(lookups.borrow().len(), 2);
7878 }
7879
7880 #[tokio::test]
7881 async fn test_start_gasp_sync_ship_discovers_peers() {
7882 let broadcaster = MockBroadcaster::new();
7883 let (engine, _calls) = make_engine_with_broadcaster(
7884 vec![0],
7885 broadcaster,
7886 vec![
7887 ("https://peer1.example.com", "tm_test"),
7888 ("https://peer2.example.com", "tm_test"),
7889 ],
7890 Some("https://self.example.com"),
7891 )
7892 .await;
7893
7894 let result = engine.start_gasp_sync().await.unwrap();
7895 assert!(
7896 result.topics_synced.contains_key("tm_test"),
7897 "tm_test should be in sync results"
7898 );
7899
7900 let topic_result = &result.topics_synced["tm_test"];
7901 assert_eq!(topic_result.sync_type, "ship");
7902 assert_eq!(
7903 topic_result.peers.len(),
7904 2,
7905 "Should discover both SHIP peers"
7906 );
7907 assert!(topic_result
7908 .peers
7909 .contains(&"https://peer1.example.com".to_string()));
7910 assert!(topic_result
7911 .peers
7912 .contains(&"https://peer2.example.com".to_string()));
7913 }
7914
7915 #[tokio::test]
7916 async fn test_start_gasp_sync_ship_filters_self() {
7917 let broadcaster = MockBroadcaster::new();
7918 let (engine, _calls) = make_engine_with_broadcaster(
7919 vec![0],
7920 broadcaster,
7921 vec![
7922 ("https://self.example.com", "tm_test"),
7923 ("https://peer2.example.com", "tm_test"),
7924 ],
7925 Some("https://self.example.com"),
7926 )
7927 .await;
7928
7929 let result = engine.start_gasp_sync().await.unwrap();
7930 let topic_result = &result.topics_synced["tm_test"];
7931 assert_eq!(
7932 topic_result.peers.len(),
7933 1,
7934 "Self URL should be filtered out from SHIP discovery"
7935 );
7936 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
7937 }
7938
7939 #[tokio::test]
7940 async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
7941 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7942 managers.insert(
7943 "tm_custom".to_string(),
7944 Box::new(MockTopicManager::admitting(vec![])),
7945 );
7946
7947 let mut sync_config: SyncConfiguration = HashMap::new();
7948 sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
7949
7950 let engine = Engine::new(
7951 managers,
7952 HashMap::new(),
7953 Box::new(MemoryStorage::new()),
7954 None,
7955 EngineConfig {
7956 sync_configuration: sync_config,
7957 ..Default::default()
7958 },
7959 );
7960
7961 let result = engine.start_gasp_sync().await.unwrap();
7962 let topic_result = &result.topics_synced["tm_custom"];
7963 assert!(
7964 topic_result.peers.is_empty(),
7965 "Without ls_ship, SHIP discovery should return no peers"
7966 );
7967 }
7968
7969 #[tokio::test]
7970 async fn test_start_gasp_sync_mixed_config() {
7971 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7972 managers.insert(
7973 "tm_peers".to_string(),
7974 Box::new(MockTopicManager::admitting(vec![])),
7975 );
7976 managers.insert(
7977 "tm_disabled".to_string(),
7978 Box::new(MockTopicManager::admitting(vec![])),
7979 );
7980 managers.insert(
7981 "tm_ship_topic".to_string(),
7982 Box::new(MockTopicManager::admitting(vec![])),
7983 );
7984
7985 let mut sync_config: SyncConfiguration = HashMap::new();
7986 sync_config.insert(
7987 "tm_peers".to_string(),
7988 SyncTarget::Peers(vec!["https://peer.com".to_string()]),
7989 );
7990 sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
7991 sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
7992
7993 let engine = Engine::new(
7994 managers,
7995 HashMap::new(),
7996 Box::new(MemoryStorage::new()),
7997 None,
7998 EngineConfig {
7999 sync_configuration: sync_config,
8000 ..Default::default()
8001 },
8002 );
8003
8004 let result = engine.start_gasp_sync().await.unwrap();
8005 assert_eq!(result.topics_synced.len(), 2);
8006 assert!(result.topics_synced.contains_key("tm_peers"));
8007 assert!(result.topics_synced.contains_key("tm_ship_topic"));
8008 assert!(!result.topics_synced.contains_key("tm_disabled"));
8009
8010 assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
8011 assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
8012 assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
8013 }
8014
8015 #[tokio::test]
8016 async fn test_gasp_sync_result_serialization() {
8017 let mut topics_synced = HashMap::new();
8018 topics_synced.insert(
8019 "tm_test".to_string(),
8020 super::TopicSyncResult {
8021 peers: vec!["https://peer.com".to_string()],
8022 sync_type: "ship".to_string(),
8023 errors: vec![],
8024 pruned_inputs: 0,
8025 discarded_graphs: 0,
8026 anchor_walk_held_graphs: 0,
8027 finalized_graphs: 0,
8028 deadline_dropped_graphs: 0,
8029 cursor_moves: Vec::new(),
8030 deferred_graphs: 0,
8031 resumed_graphs: 0,
8032 converged_graphs: 0,
8033 dropped_graphs: Vec::new(),
8034 stalled_graphs: 0,
8035 held_back_graphs: 0,
8036 },
8037 );
8038
8039 let result = super::GASPSyncResult { topics_synced };
8040 let json = serde_json::to_string(&result).unwrap();
8041 let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
8042
8043 assert_eq!(back.topics_synced.len(), 1);
8044 assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
8045 assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
8046 }
8047
8048 // ── GASP Sync With Factory Tests ─────────────────────────────────
8049
8050 /// Mock GASPRemote for factory tests
8051 struct MockSyncRemote {
8052 utxos: Vec<crate::types::GASPOutput>,
8053 }
8054
8055 #[async_trait(?Send)]
8056 impl crate::gasp::GASPRemote for MockSyncRemote {
8057 async fn get_initial_response(
8058 &self,
8059 request: &crate::types::GASPInitialRequest,
8060 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
8061 let utxos: Vec<crate::types::GASPOutput> = self
8062 .utxos
8063 .iter()
8064 .filter(|u| u.score as u64 >= request.since)
8065 .cloned()
8066 .collect();
8067 Ok(crate::types::GASPInitialResponse {
8068 utxo_list: utxos,
8069 since: request.since,
8070 })
8071 }
8072 async fn get_initial_reply(
8073 &self,
8074 _: &crate::types::GASPInitialResponse,
8075 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
8076 Ok(crate::types::GASPInitialReply {
8077 utxo_list: Vec::new(),
8078 })
8079 }
8080 async fn request_node(
8081 &self,
8082 graph_id: &str,
8083 txid: &str,
8084 output_index: u32,
8085 _: bool,
8086 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
8087 Ok(crate::types::GASPNode {
8088 graph_id: graph_id.to_string(),
8089 raw_tx: format!("rawtx_{txid}"),
8090 output_index,
8091 proof: None,
8092 tx_metadata: None,
8093 output_metadata: None,
8094 inputs: None,
8095 })
8096 }
8097 async fn submit_node(
8098 &self,
8099 _: &crate::types::GASPNode,
8100 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
8101 Ok(None)
8102 }
8103 }
8104
8105 /// Mock factory that creates MockSyncRemote instances
8106 struct MockGASPRemoteFactory;
8107
8108 #[derive(Clone)]
8109 struct BudgetPageRemote {
8110 outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
8111 nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
8112 elapsed: std::rc::Rc<Cell<u64>>,
8113 requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
8114 clamp: Option<usize>,
8115 node_cost_ms: u64,
8116 }
8117
8118 #[async_trait(?Send)]
8119 impl crate::gasp::GASPRemote for BudgetPageRemote {
8120 async fn get_initial_response(
8121 &self,
8122 request: &crate::types::GASPInitialRequest,
8123 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
8124 self.requests.borrow_mut().push(request.clone());
8125 let page = self
8126 .outputs
8127 .iter()
8128 .filter(|u| u.score as u64 >= request.since);
8129 let limit = self.clamp.map_or(usize::MAX, |cap| {
8130 cap.min(request.limit.unwrap_or(u64::MAX) as usize)
8131 });
8132 Ok(crate::types::GASPInitialResponse {
8133 utxo_list: page.take(limit).cloned().collect(),
8134 since: request.since,
8135 })
8136 }
8137
8138 async fn get_initial_reply(
8139 &self,
8140 _: &crate::types::GASPInitialResponse,
8141 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
8142 unreachable!()
8143 }
8144
8145 async fn request_node(
8146 &self,
8147 graph_id: &str,
8148 txid: &str,
8149 _: u32,
8150 _: bool,
8151 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
8152 self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
8153 if self.node_cost_ms > 0 {
8154 let mut yielded = false;
8155 std::future::poll_fn(|cx| {
8156 if yielded {
8157 std::task::Poll::Ready(())
8158 } else {
8159 yielded = true;
8160 cx.waker().wake_by_ref();
8161 std::task::Poll::Pending
8162 }
8163 })
8164 .await;
8165 }
8166 let mut node = self
8167 .nodes
8168 .get(txid)
8169 .cloned()
8170 .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
8171 node.graph_id = graph_id.to_string();
8172 Ok(node)
8173 }
8174
8175 async fn submit_node(
8176 &self,
8177 _: &crate::types::GASPNode,
8178 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
8179 unreachable!()
8180 }
8181 }
8182
8183 impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
8184 fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
8185 Box::new(self.clone())
8186 }
8187 }
8188
8189 fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
8190 let mut engine = make_engine(vec![0]);
8191 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
8192 let mut outputs = Vec::new();
8193 let mut nodes = HashMap::new();
8194 for n in 1..=count {
8195 let tx = child_of(&format!("{n:064x}"), 0);
8196 let txid = tx.id();
8197 tracker.add_root(n, txid.clone());
8198 outputs.push(crate::types::GASPOutput {
8199 txid: txid.clone(),
8200 output_index: 0,
8201 score: f64::from(n * 10),
8202 });
8203 nodes.insert(
8204 txid.clone(),
8205 crate::types::GASPNode {
8206 graph_id: String::new(),
8207 raw_tx: tx.to_hex(),
8208 output_index: 0,
8209 proof: Some(single_leaf_bump_hex(&txid, n)),
8210 tx_metadata: None,
8211 output_metadata: None,
8212 inputs: None,
8213 },
8214 );
8215 }
8216 engine.chain_tracker = Some(Box::new(tracker));
8217 engine.config.sync_configuration.insert(
8218 "tm_test".to_string(),
8219 SyncTarget::Peers(vec!["https://budget.example".to_string()]),
8220 );
8221 let remote = BudgetPageRemote {
8222 outputs: std::rc::Rc::new(outputs),
8223 nodes: std::rc::Rc::new(nodes),
8224 elapsed: std::rc::Rc::new(Cell::new(0)),
8225 requests: std::rc::Rc::default(),
8226 clamp: None,
8227 node_cost_ms: 5000,
8228 };
8229 (engine, remote)
8230 }
8231
8232 fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
8233 let elapsed = remote.elapsed.clone();
8234 engine.set_peer_sync_budget(
8235 std::rc::Rc::new(move |ms| {
8236 assert_eq!(ms, 30_000);
8237 let elapsed = elapsed.clone();
8238 let until = elapsed.get() + ms;
8239 Box::pin(std::future::poll_fn(move |_| {
8240 if elapsed.get() >= until {
8241 std::task::Poll::Ready(())
8242 } else {
8243 std::task::Poll::Pending
8244 }
8245 }))
8246 }),
8247 30_000,
8248 );
8249 }
8250
8251 #[tokio::test]
8252 async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
8253 // This responder ignores the limit: the initiator must safely handle
8254 // a 3,000-output page under a virtual 30-second peer budget.
8255 let (mut engine, remote) = budget_page_engine(3000);
8256 virtual_peer_budget(&mut engine, &remote);
8257 engine.set_gasp_remote_factory(Box::new(remote.clone()));
8258 for round in 1..=2 {
8259 let result = engine.start_gasp_sync().await.unwrap();
8260 let topic = &result.topics_synced["tm_test"];
8261 assert_eq!(topic.finalized_graphs, 5);
8262 assert_eq!(topic.deadline_dropped_graphs, 1);
8263 assert_eq!(topic.cursor_moves.len(), 1);
8264 let expected = round * 50;
8265 assert_eq!(topic.cursor_moves[0].to, expected);
8266 assert_eq!(
8267 engine
8268 .storage
8269 .get_last_interaction("https://budget.example", "tm_test")
8270 .await
8271 .unwrap(),
8272 expected
8273 );
8274 {
8275 let requests = remote.requests.borrow();
8276 assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
8277 }
8278 for output in remote.outputs.iter().take((round * 5) as usize) {
8279 assert!(engine
8280 .storage
8281 .find_output(&output.txid, 0, Some("tm_test"), None, true)
8282 .await
8283 .unwrap()
8284 .is_some());
8285 }
8286 let unfinished = &remote.outputs[(round * 5) as usize];
8287 assert!(engine
8288 .storage
8289 .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
8290 .await
8291 .unwrap()
8292 .is_none());
8293 }
8294 }
8295
8296 #[tokio::test]
8297 async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
8298 let (mut engine, mut remote) = budget_page_engine(7);
8299 remote.clamp = Some(3);
8300 remote.node_cost_ms = 0;
8301 virtual_peer_budget(&mut engine, &remote);
8302 engine.set_gasp_remote_factory(Box::new(remote.clone()));
8303 let result = engine.start_gasp_sync().await.unwrap();
8304 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
8305 let requests = remote.requests.borrow();
8306 assert_eq!(requests.len(), 4);
8307 assert!(requests.iter().all(|request| request.limit == Some(100)));
8308 assert_eq!(
8309 requests.iter().map(|r| r.since).collect::<Vec<_>>(),
8310 vec![0, 30, 50, 70]
8311 );
8312 }
8313
8314 #[tokio::test]
8315 async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
8316 let (mut engine, mut remote) = budget_page_engine(3000);
8317 std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
8318 virtual_peer_budget(&mut engine, &remote);
8319 engine.set_gasp_remote_factory(Box::new(remote));
8320 let result = engine.start_gasp_sync().await.unwrap();
8321 let topic = &result.topics_synced["tm_test"];
8322 assert_eq!(topic.finalized_graphs, 5);
8323 // Score 100 landed, but score 10 is still ahead in this unordered
8324 // page. The unfinished lower score must hold the cursor at entry.
8325 assert!(topic.cursor_moves.is_empty());
8326 }
8327
8328 #[tokio::test]
8329 async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
8330 let (mut engine, mut remote) = budget_page_engine(3);
8331 std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
8332 remote.node_cost_ms = 10_000;
8333 virtual_peer_budget(&mut engine, &remote);
8334 engine.set_gasp_remote_factory(Box::new(remote));
8335 let result = engine.start_gasp_sync().await.unwrap();
8336 let topic = &result.topics_synced["tm_test"];
8337 assert_eq!(topic.finalized_graphs, 2);
8338 assert_eq!(topic.cursor_moves[0].to, 10);
8339 }
8340
8341 #[tokio::test]
8342 async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
8343 let (mut engine, mut remote) = budget_page_engine(3000);
8344 let failed = remote.outputs[2].txid.clone();
8345 std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
8346 virtual_peer_budget(&mut engine, &remote);
8347 engine.set_gasp_remote_factory(Box::new(remote));
8348 let result = engine.start_gasp_sync().await.unwrap();
8349 let topic = &result.topics_synced["tm_test"];
8350 assert_eq!(topic.finalized_graphs, 4);
8351 assert_eq!(topic.cursor_moves[0].to, 20);
8352 }
8353
8354 struct ProofOnlyRootFetcher {
8355 proofs: HashMap<String, String>,
8356 requested: std::rc::Rc<Cell<u64>>,
8357 }
8358
8359 #[async_trait(?Send)]
8360 impl AncestorFetcher for ProofOnlyRootFetcher {
8361 async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
8362 Err(GASPError::RemoteError("raw source unavailable".to_string()))
8363 }
8364 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
8365 self.requested.set(self.requested.get() + 1);
8366 self.proofs.get(txid).cloned()
8367 }
8368 }
8369
8370 #[tokio::test]
8371 async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
8372 for proven in [true, false] {
8373 let (mut engine, mut remote) = budget_page_engine(1);
8374 let txid = remote.outputs[0].txid.clone();
8375 let proof = remote.nodes[&txid].proof.clone().unwrap();
8376 std::rc::Rc::make_mut(&mut remote.nodes)
8377 .get_mut(&txid)
8378 .unwrap()
8379 .proof = None;
8380 let requested = std::rc::Rc::new(Cell::new(0));
8381 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8382 proofs: if proven {
8383 HashMap::from([(txid.clone(), proof)])
8384 } else {
8385 HashMap::new()
8386 },
8387 requested: requested.clone(),
8388 }));
8389 virtual_peer_budget(&mut engine, &remote);
8390 engine.set_gasp_remote_factory(Box::new(remote));
8391 let result = engine.start_gasp_sync().await.unwrap();
8392 assert!(requested.get() > 0);
8393 assert_eq!(
8394 result.topics_synced["tm_test"].finalized_graphs,
8395 u64::from(proven)
8396 );
8397 assert_eq!(
8398 engine
8399 .storage
8400 .find_output(&txid, 0, Some("tm_test"), None, true)
8401 .await
8402 .unwrap()
8403 .is_some(),
8404 proven
8405 );
8406 if !proven {
8407 assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
8408 }
8409 }
8410 }
8411
8412 #[tokio::test]
8413 async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
8414 let (mut engine, mut remote) = budget_page_engine(1);
8415 let txid = remote.outputs[0].txid.clone();
8416 let proof = remote.nodes[&txid].proof.clone().unwrap();
8417 std::rc::Rc::make_mut(&mut remote.nodes)
8418 .get_mut(&txid)
8419 .unwrap()
8420 .proof = None;
8421 let requested = std::rc::Rc::new(Cell::new(0));
8422 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8423 proofs: HashMap::new(),
8424 requested: requested.clone(),
8425 }));
8426 virtual_peer_budget(&mut engine, &remote);
8427 engine.set_graph_budget(
8428 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
8429 5,
8430 5000,
8431 );
8432 engine.set_gasp_remote_factory(Box::new(remote));
8433 let held = engine.start_gasp_sync().await.unwrap();
8434 assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
8435 assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
8436 assert!(held.topics_synced["tm_test"].cursor_moves.is_empty());
8437 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8438 proofs: HashMap::from([(txid.clone(), proof)]),
8439 requested,
8440 }));
8441 let landed = engine.start_gasp_sync().await.unwrap();
8442 assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
8443 assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
8444 assert_eq!(landed.topics_synced["tm_test"].cursor_moves[0].to, 10);
8445 assert!(engine
8446 .storage
8447 .find_output(&txid, 0, Some("tm_test"), None, true)
8448 .await
8449 .unwrap()
8450 .is_some());
8451 }
8452
8453 #[tokio::test]
8454 async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
8455 let (mut engine, mut remote) = budget_page_engine(1);
8456 let txid = remote.outputs[0].txid.clone();
8457 std::rc::Rc::make_mut(&mut remote.nodes)
8458 .get_mut(&txid)
8459 .unwrap()
8460 .proof = None;
8461 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8462 proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
8463 requested: std::rc::Rc::default(),
8464 }));
8465 virtual_peer_budget(&mut engine, &remote);
8466 engine.set_gasp_remote_factory(Box::new(remote));
8467 let result = engine.start_gasp_sync().await.unwrap();
8468 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
8469 assert!(engine
8470 .storage
8471 .find_output(&txid, 0, Some("tm_test"), None, true)
8472 .await
8473 .unwrap()
8474 .is_none());
8475 }
8476
8477 impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
8478 fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8479 Box::new(MockSyncRemote {
8480 utxos: vec![
8481 crate::types::GASPOutput {
8482 txid: "remote_tx1".to_string(),
8483 output_index: 0,
8484 score: 100.0,
8485 },
8486 crate::types::GASPOutput {
8487 txid: "remote_tx2".to_string(),
8488 output_index: 0,
8489 score: 200.0,
8490 },
8491 ],
8492 })
8493 }
8494 }
8495
8496 #[tokio::test]
8497 async fn test_start_gasp_sync_with_factory_runs_sync() {
8498 let mut sync_config: SyncConfiguration = HashMap::new();
8499 sync_config.insert(
8500 "tm_test".to_string(),
8501 SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
8502 );
8503
8504 let mut engine = Engine::new(
8505 HashMap::new(),
8506 HashMap::new(),
8507 Box::new(MemoryStorage::new()),
8508 None,
8509 EngineConfig {
8510 sync_configuration: sync_config,
8511 ..Default::default()
8512 },
8513 );
8514
8515 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
8516
8517 let result = engine.start_gasp_sync().await.unwrap();
8518 assert_eq!(result.topics_synced.len(), 1);
8519
8520 let topic_result = &result.topics_synced["tm_test"];
8521 assert_eq!(topic_result.peers.len(), 1);
8522 assert!(
8523 topic_result.errors.is_empty(),
8524 "Sync should succeed with mock remote"
8525 );
8526
8527 // Verify last_interaction was persisted
8528 let last = engine
8529 .storage()
8530 .get_last_interaction("https://peer1.example.com", "tm_test")
8531 .await
8532 .unwrap();
8533 // Synthetic nodes cannot assemble into BEEF. No score completed,
8534 // so the entry cursor stays and the next sync re-requests both.
8535 assert_eq!(
8536 last, 0,
8537 "cursor must remain at entry when no output completed"
8538 );
8539 }
8540
8541 #[tokio::test]
8542 async fn test_start_gasp_sync_with_factory_handles_error() {
8543 struct FailingFactory;
8544
8545 impl crate::gasp::GASPRemoteFactory for FailingFactory {
8546 fn create_remote(
8547 &self,
8548 _peer_url: &str,
8549 _topic: &str,
8550 ) -> Box<dyn crate::gasp::GASPRemote> {
8551 struct FailingRemote;
8552 #[async_trait(?Send)]
8553 impl crate::gasp::GASPRemote for FailingRemote {
8554 async fn get_initial_response(
8555 &self,
8556 _: &crate::types::GASPInitialRequest,
8557 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
8558 {
8559 Err(crate::gasp::GASPError::RemoteError(
8560 "connection refused".into(),
8561 ))
8562 }
8563 async fn get_initial_reply(
8564 &self,
8565 _: &crate::types::GASPInitialResponse,
8566 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
8567 {
8568 unreachable!()
8569 }
8570 async fn request_node(
8571 &self,
8572 _: &str,
8573 _: &str,
8574 _: u32,
8575 _: bool,
8576 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
8577 {
8578 unreachable!()
8579 }
8580 async fn submit_node(
8581 &self,
8582 _: &crate::types::GASPNode,
8583 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
8584 {
8585 unreachable!()
8586 }
8587 }
8588 Box::new(FailingRemote)
8589 }
8590 }
8591
8592 let mut sync_config: SyncConfiguration = HashMap::new();
8593 sync_config.insert(
8594 "tm_test".to_string(),
8595 SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
8596 );
8597
8598 let mut engine = Engine::new(
8599 HashMap::new(),
8600 HashMap::new(),
8601 Box::new(MemoryStorage::new()),
8602 None,
8603 EngineConfig {
8604 sync_configuration: sync_config,
8605 ..Default::default()
8606 },
8607 );
8608
8609 engine.set_gasp_remote_factory(Box::new(FailingFactory));
8610
8611 let result = engine.start_gasp_sync().await.unwrap();
8612 assert_eq!(result.topics_synced.len(), 1);
8613
8614 let topic_result = &result.topics_synced["tm_test"];
8615 assert_eq!(
8616 topic_result.errors.len(),
8617 1,
8618 "Should have one error for the failing peer"
8619 );
8620 assert!(topic_result.errors[0].contains("connection refused"));
8621 }
8622
8623 #[tokio::test]
8624 async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
8625 let mut sync_config: SyncConfiguration = HashMap::new();
8626 sync_config.insert(
8627 "tm_test".to_string(),
8628 SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
8629 );
8630
8631 // No factory set — should still work (just peer discovery, no sync)
8632 let engine = Engine::new(
8633 HashMap::new(),
8634 HashMap::new(),
8635 Box::new(MemoryStorage::new()),
8636 None,
8637 EngineConfig {
8638 sync_configuration: sync_config,
8639 ..Default::default()
8640 },
8641 );
8642
8643 let result = engine.start_gasp_sync().await.unwrap();
8644 assert_eq!(result.topics_synced.len(), 1);
8645
8646 let topic_result = &result.topics_synced["tm_test"];
8647 assert_eq!(topic_result.peers.len(), 1);
8648 assert!(topic_result.errors.is_empty());
8649 }
8650
8651 #[tokio::test]
8652 async fn test_start_gasp_sync_with_factory_multiple_peers() {
8653 let mut sync_config: SyncConfiguration = HashMap::new();
8654 sync_config.insert(
8655 "tm_test".to_string(),
8656 SyncTarget::Peers(vec![
8657 "https://peer1.example.com".to_string(),
8658 "https://peer2.example.com".to_string(),
8659 ]),
8660 );
8661
8662 let mut engine = Engine::new(
8663 HashMap::new(),
8664 HashMap::new(),
8665 Box::new(MemoryStorage::new()),
8666 None,
8667 EngineConfig {
8668 sync_configuration: sync_config,
8669 ..Default::default()
8670 },
8671 );
8672
8673 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
8674
8675 let result = engine.start_gasp_sync().await.unwrap();
8676 let topic_result = &result.topics_synced["tm_test"];
8677 assert_eq!(topic_result.peers.len(), 2);
8678 assert!(topic_result.errors.is_empty());
8679
8680 // Neither peer completed its synthetic graphs.
8681 let last1 = engine
8682 .storage()
8683 .get_last_interaction("https://peer1.example.com", "tm_test")
8684 .await
8685 .unwrap();
8686 let last2 = engine
8687 .storage()
8688 .get_last_interaction("https://peer2.example.com", "tm_test")
8689 .await
8690 .unwrap();
8691 assert_eq!(last1, 0);
8692 assert_eq!(last2, 0);
8693 }
8694
8695 // ── Per-peer budget + dead-peer quarantine (bsv-low#302) ──────────
8696
8697 /// A remote whose initial request HANGS forever — models the dead
8698 /// ephemeral peers (ngrok tunnels) whose fetch never returns, the #257
8699 /// root cause the per-peer budget exists for.
8700 struct HangingRemote;
8701
8702 #[async_trait(?Send)]
8703 impl crate::gasp::GASPRemote for HangingRemote {
8704 async fn get_initial_response(
8705 &self,
8706 _: &crate::types::GASPInitialRequest,
8707 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
8708 std::future::pending::<()>().await;
8709 unreachable!()
8710 }
8711 async fn get_initial_reply(
8712 &self,
8713 _: &crate::types::GASPInitialResponse,
8714 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
8715 unreachable!()
8716 }
8717 async fn request_node(
8718 &self,
8719 _: &str,
8720 _: &str,
8721 _: u32,
8722 _: bool,
8723 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
8724 unreachable!()
8725 }
8726 async fn submit_node(
8727 &self,
8728 _: &crate::types::GASPNode,
8729 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
8730 unreachable!()
8731 }
8732 }
8733
8734 /// Factory that HANGS for `hang.example.com` peers and serves the normal
8735 /// mock sync remote for everyone else, counting `create_remote` calls
8736 /// (a quarantine-skipped peer never reaches the factory).
8737 struct SelectiveHangFactory {
8738 created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
8739 }
8740
8741 impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
8742 fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8743 self.created.borrow_mut().push(peer_url.to_string());
8744 if peer_url.contains("hang.example.com") {
8745 Box::new(HangingRemote)
8746 } else {
8747 Box::new(MockSyncRemote {
8748 utxos: vec![crate::types::GASPOutput {
8749 txid: "remote_tx1".to_string(),
8750 output_index: 0,
8751 score: 100.0,
8752 }],
8753 })
8754 }
8755 }
8756 }
8757
8758 /// The deterministic "instant deadline" sleep factory: `race_or_deadline`
8759 /// polls the sync future FIRST, so an in-memory sync that never yields
8760 /// still completes; only a future that actually returns Pending (the
8761 /// hanging remote) loses the race.
8762 fn instant_deadline() -> crate::engine::SleepFactory {
8763 std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
8764 }
8765
8766 fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
8767 let mut sync_config: SyncConfiguration = HashMap::new();
8768 sync_config.insert(
8769 "tm_test".to_string(),
8770 SyncTarget::Peers(vec![
8771 "https://hang.example.com".to_string(),
8772 "https://good.example.com".to_string(),
8773 ]),
8774 );
8775 Engine::new(
8776 HashMap::new(),
8777 HashMap::new(),
8778 Box::new(store),
8779 None,
8780 EngineConfig {
8781 sync_configuration: sync_config,
8782 ..Default::default()
8783 },
8784 )
8785 }
8786
8787 #[tokio::test]
8788 async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
8789 let store = std::rc::Rc::new(MemoryStorage::new());
8790 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8791 let mut engine = two_peer_engine(store.clone());
8792 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
8793 created: created.clone(),
8794 }));
8795 engine.set_peer_sync_budget(instant_deadline(), 1);
8796
8797 let result = engine.start_gasp_sync().await.unwrap();
8798 let topic_result = &result.topics_synced["tm_test"];
8799
8800 // The hanging peer was dropped LOUDLY (an error entry) and the loop
8801 // CONTINUED: the good peer was still attempted and completed.
8802 assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
8803 assert!(
8804 topic_result.errors[0].contains("budget"),
8805 "{:?}",
8806 topic_result.errors
8807 );
8808 assert_eq!(
8809 created.borrow().as_slice(),
8810 ["https://hang.example.com", "https://good.example.com"],
8811 "both peers attempted this tick"
8812 );
8813
8814 // A timed-out peer stays at entry. The other peer was reached but
8815 // served synthetic graphs that cannot complete, so its cursor stays too.
8816 let hang_cursor = store
8817 .get_last_interaction("https://hang.example.com", "tm_test")
8818 .await
8819 .unwrap();
8820 assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
8821 let good_cursor = store
8822 .get_last_interaction("https://good.example.com", "tm_test")
8823 .await
8824 .unwrap();
8825 assert_eq!(good_cursor, 0, "no synthetic graph completed");
8826
8827 // Health bookkeeping: timeout = failure, completion = success.
8828 let hang_health = store
8829 .get_peer_sync_health("hang.example.com", "tm_test")
8830 .await
8831 .unwrap();
8832 assert_eq!(hang_health.consecutive_failures, 1);
8833 let good_health = store
8834 .get_peer_sync_health("good.example.com", "tm_test")
8835 .await
8836 .unwrap();
8837 assert_eq!(good_health.consecutive_failures, 0);
8838 }
8839
8840 #[tokio::test]
8841 async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
8842 let store = std::rc::Rc::new(MemoryStorage::new());
8843 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8844
8845 let mut sync_config: SyncConfiguration = HashMap::new();
8846 sync_config.insert(
8847 "tm_test".to_string(),
8848 SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
8849 );
8850 let mut engine = Engine::new(
8851 HashMap::new(),
8852 HashMap::new(),
8853 Box::new(store.clone()),
8854 None,
8855 EngineConfig {
8856 sync_configuration: sync_config,
8857 ..Default::default()
8858 },
8859 );
8860 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
8861 created: created.clone(),
8862 }));
8863 engine.set_peer_sync_budget(instant_deadline(), 1);
8864
8865 // Fail the peer up to (but not past) the quarantine threshold — every
8866 // tick attempts it (advance the clock between ticks so the attempts
8867 // are distinguishable in age).
8868 for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
8869 engine.start_gasp_sync().await.unwrap();
8870 store.advance_clock(900);
8871 assert_eq!(
8872 created.borrow().len() as u64,
8873 i + 1,
8874 "peer attempted while below the threshold"
8875 );
8876 }
8877 let health = store
8878 .get_peer_sync_health("hang.example.com", "tm_test")
8879 .await
8880 .unwrap();
8881 assert_eq!(
8882 health.consecutive_failures,
8883 crate::gasp::PEER_QUARANTINE_THRESHOLD
8884 );
8885
8886 // Next tick: QUARANTINED — the factory is never consulted, no error
8887 // is emitted, and the attempt count does not grow (a skip is not an
8888 // attempt, so the re-probe window keeps aging).
8889 let result = engine.start_gasp_sync().await.unwrap();
8890 assert!(result.topics_synced["tm_test"].errors.is_empty());
8891 assert_eq!(
8892 created.borrow().len() as u64,
8893 crate::gasp::PEER_QUARANTINE_THRESHOLD,
8894 "quarantined peer must be skipped"
8895 );
8896
8897 // Age past the re-probe window: exactly one fresh probe is allowed.
8898 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
8899 engine.start_gasp_sync().await.unwrap();
8900 assert_eq!(
8901 created.borrow().len() as u64,
8902 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
8903 "re-probe attempted after the window"
8904 );
8905 // The probe failed again → failure count kept growing → re-armed.
8906 let health = store
8907 .get_peer_sync_health("hang.example.com", "tm_test")
8908 .await
8909 .unwrap();
8910 assert_eq!(
8911 health.consecutive_failures,
8912 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
8913 );
8914 let result = engine.start_gasp_sync().await.unwrap();
8915 assert!(
8916 result.topics_synced["tm_test"].errors.is_empty(),
8917 "re-quarantined"
8918 );
8919
8920 // The peer comes back to life: age to the next re-probe, then answer
8921 // like a healthy peer → SUCCESS resets the count to 0 (full
8922 // re-admission — quarantine is never a deletion).
8923 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
8924 // Swap the factory for one that serves the healthy mock for this URL.
8925 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
8926 let result = engine.start_gasp_sync().await.unwrap();
8927 assert!(result.topics_synced["tm_test"].errors.is_empty());
8928 let health = store
8929 .get_peer_sync_health("hang.example.com", "tm_test")
8930 .await
8931 .unwrap();
8932 assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
8933 }
8934
8935 // ── Mock ARC Broadcaster ─────────────────────────────────────────
8936
8937 use crate::broadcaster::ArcBroadcaster;
8938
8939 struct MockArcBroadcaster {
8940 calls: Arc<Mutex<Vec<String>>>,
8941 should_fail: bool,
8942 }
8943
8944 impl MockArcBroadcaster {
8945 fn new() -> Self {
8946 Self {
8947 calls: Arc::new(Mutex::new(Vec::new())),
8948 should_fail: false,
8949 }
8950 }
8951
8952 fn failing() -> Self {
8953 Self {
8954 calls: Arc::new(Mutex::new(Vec::new())),
8955 should_fail: true,
8956 }
8957 }
8958 }
8959
8960 #[async_trait(?Send)]
8961 impl ArcBroadcaster for MockArcBroadcaster {
8962 async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
8963 self.calls.lock().unwrap().push(raw_tx_hex.to_string());
8964 if self.should_fail {
8965 Err("mock ARC failure".to_string())
8966 } else {
8967 Ok("mock_txid_from_arc".to_string())
8968 }
8969 }
8970 }
8971
8972 /// Create engine with an ARC broadcaster (and optionally a SHIP broadcaster).
8973 fn make_engine_with_arc(
8974 admit_indices: Vec<u32>,
8975 arc: MockArcBroadcaster,
8976 ) -> (Engine, Arc<Mutex<Vec<String>>>) {
8977 let arc_calls = arc.calls.clone();
8978
8979 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8980 managers.insert(
8981 "tm_test".to_string(),
8982 Box::new(MockTopicManager::admitting(admit_indices)),
8983 );
8984
8985 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8986 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
8987
8988 let storage = Box::new(MemoryStorage::new());
8989
8990 let engine = Engine::with_all(
8991 managers,
8992 lookup_services,
8993 storage,
8994 None,
8995 None,
8996 Some(Box::new(arc)),
8997 None,
8998 EngineConfig::default(),
8999 );
9000
9001 (engine, arc_calls)
9002 }
9003
9004 // ── ARC Broadcaster Tests ────────────────────────────────────────
9005
9006 #[tokio::test]
9007 async fn test_arc_broadcaster_called_on_current_tx() {
9008 let arc = MockArcBroadcaster::new();
9009 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9010
9011 let beef = test_tagged_beef(vec!["tm_test"]);
9012 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
9013
9014 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
9015
9016 let calls = arc_calls.lock().unwrap();
9017 assert_eq!(
9018 calls.len(),
9019 1,
9020 "ARC broadcaster should be called once for CurrentTx"
9021 );
9022 assert!(
9023 !calls[0].is_empty(),
9024 "ARC should receive non-empty raw tx hex"
9025 );
9026 }
9027
9028 #[tokio::test]
9029 async fn test_arc_broadcaster_not_called_on_historical_tx() {
9030 let arc = MockArcBroadcaster::new();
9031 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9032
9033 let beef = test_tagged_beef(vec!["tm_test"]);
9034 engine
9035 .submit(&beef, SubmitMode::HistoricalTx)
9036 .await
9037 .unwrap();
9038
9039 assert_eq!(
9040 arc_calls.lock().unwrap().len(),
9041 0,
9042 "ARC broadcaster should NOT be called for HistoricalTx"
9043 );
9044 }
9045
9046 #[tokio::test]
9047 async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
9048 let arc = MockArcBroadcaster::new();
9049 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9050
9051 let beef = test_tagged_beef(vec!["tm_test"]);
9052 engine
9053 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
9054 .await
9055 .unwrap();
9056
9057 assert_eq!(
9058 arc_calls.lock().unwrap().len(),
9059 0,
9060 "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
9061 );
9062 }
9063
9064 #[tokio::test]
9065 async fn test_arc_broadcast_failure_does_not_fail_submit() {
9066 let arc = MockArcBroadcaster::failing();
9067 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9068
9069 let beef = test_tagged_beef(vec!["tm_test"]);
9070 // submit should succeed even when ARC fails
9071 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
9072
9073 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
9074
9075 // ARC was called (and failed), but submit still succeeded
9076 assert_eq!(
9077 arc_calls.lock().unwrap().len(),
9078 1,
9079 "ARC broadcaster should be attempted even if it will fail"
9080 );
9081 }
9082
9083 #[tokio::test]
9084 async fn test_arc_broadcaster_not_present_still_works() {
9085 // Engine without ARC broadcaster — should work fine
9086 let engine = make_engine(vec![0]);
9087
9088 let beef = test_tagged_beef(vec!["tm_test"]);
9089 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
9090
9091 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
9092 }
9093
9094 // ── Sync advertisements URL validation ─────────────────────────────
9095
9096 /// Mock Advertiser that tracks created advertisements via shared state.
9097 struct TrackingAdvertiser {
9098 created: Arc<Mutex<Vec<AdvertisementData>>>,
9099 existing_ship: Vec<Advertisement>,
9100 existing_slap: Vec<Advertisement>,
9101 /// Topics the returned TaggedBEEF is tagged with — a topic with no
9102 /// registered manager forces the LOCAL submit to fail (#320 3a);
9103 /// several topics exercise independent per-topic admission (D1).
9104 tag_topics: Vec<String>,
9105 /// Simulate a create_advertisements failure (#320 3a).
9106 create_fails: bool,
9107 /// Simulate a find_all_advertisements failure (#320 M2).
9108 find_fails: bool,
9109 /// Return the v1 no-op EMPTY TaggedBEEF from revoke (#320 L2).
9110 revoke_empty: bool,
9111 }
9112
9113 impl TrackingAdvertiser {
9114 fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
9115 let created = Arc::new(Mutex::new(Vec::new()));
9116 (
9117 Self {
9118 created: created.clone(),
9119 existing_ship: vec![],
9120 existing_slap: vec![],
9121 tag_topics: vec!["tm_test".to_string()],
9122 create_fails: false,
9123 find_fails: false,
9124 revoke_empty: false,
9125 },
9126 created,
9127 )
9128 }
9129 }
9130
9131 use crate::advertiser::{Advertiser, AdvertiserError};
9132
9133 #[async_trait(?Send)]
9134 impl Advertiser for TrackingAdvertiser {
9135 async fn create_advertisements(
9136 &self,
9137 ads: &[AdvertisementData],
9138 ) -> Result<TaggedBEEF, AdvertiserError> {
9139 if self.create_fails {
9140 return Err(AdvertiserError::CreationFailed(
9141 "mock create refused".into(),
9142 ));
9143 }
9144 self.created.lock().unwrap().extend(ads.iter().cloned());
9145 Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
9146 }
9147
9148 async fn find_all_advertisements(
9149 &self,
9150 protocol: Protocol,
9151 ) -> Result<Vec<Advertisement>, AdvertiserError> {
9152 if self.find_fails {
9153 return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
9154 }
9155 match protocol {
9156 Protocol::Ship => Ok(self.existing_ship.clone()),
9157 Protocol::Slap => Ok(self.existing_slap.clone()),
9158 }
9159 }
9160
9161 async fn revoke_advertisements(
9162 &self,
9163 _ads: &[Advertisement],
9164 ) -> Result<TaggedBEEF, AdvertiserError> {
9165 if self.revoke_empty {
9166 return Ok(TaggedBEEF::new(vec![], vec![]));
9167 }
9168 Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
9169 }
9170
9171 fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
9172 None
9173 }
9174 }
9175
9176 /// TS: "Sync advertisements URL validation"
9177 /// sync_advertisements should be a no-op when hosting_url is empty string,
9178 /// and should skip suppressed topics (tm_ship/tm_slap) when configured.
9179 #[tokio::test]
9180 async fn test_sync_advertisements_url_validation() {
9181 // Case 1: Empty hosting URL — should return early without creating ads
9182 let (adv, created) = TrackingAdvertiser::new();
9183
9184 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9185 managers.insert(
9186 "tm_test".to_string(),
9187 Box::new(MockTopicManager::admitting(vec![0])),
9188 );
9189
9190 let engine = Engine::new(
9191 managers,
9192 HashMap::new(),
9193 Box::new(MemoryStorage::new()),
9194 Some(Box::new(adv)),
9195 EngineConfig {
9196 hosting_url: Some(String::new()), // empty string — invalid
9197 ..Default::default()
9198 },
9199 );
9200
9201 engine.sync_advertisements().await.unwrap();
9202 assert!(
9203 created.lock().unwrap().is_empty(),
9204 "Empty hosting URL should skip advertisement creation"
9205 );
9206
9207 // Case 2: Valid hosting URL — should create SHIP advertisements for non-suppressed topics
9208 let (adv2, created2) = TrackingAdvertiser::new();
9209
9210 let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9211 managers2.insert(
9212 "tm_test".to_string(),
9213 Box::new(MockTopicManager::admitting(vec![0])),
9214 );
9215 // Also register tm_ship to verify it gets suppressed
9216 managers2.insert(
9217 "tm_ship".to_string(),
9218 Box::new(MockTopicManager::admitting(vec![])),
9219 );
9220
9221 let engine2 = Engine::new(
9222 managers2,
9223 HashMap::new(),
9224 Box::new(MemoryStorage::new()),
9225 Some(Box::new(adv2)),
9226 EngineConfig {
9227 hosting_url: Some("https://valid.example.com".to_string()),
9228 suppress_default_sync_advertisements: true,
9229 ..Default::default()
9230 },
9231 );
9232
9233 engine2.sync_advertisements().await.unwrap();
9234 let ads = created2.lock().unwrap();
9235 // tm_ship should be suppressed, only tm_test should get an advertisement
9236 assert!(
9237 ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
9238 "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
9239 );
9240 assert!(
9241 ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
9242 "tm_test should get a SHIP advertisement"
9243 );
9244 }
9245
9246 // ── sync_advertisements report (bsv-low #320 defect 3a) ────────────
9247 //
9248 // Pre-#320 the engine returned `Ok(())` unconditionally: a failed
9249 // create or a failed LOCAL submit was `error!`-logged and invisible to
9250 // the caller — the admin route said `success` while the node's own
9251 // ls_ship/ls_slap never gained its ads, so every cycle re-created the
9252 // whole set. These cells pin the report contract.
9253
9254 fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
9255 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9256 managers.insert(
9257 "tm_test".to_string(),
9258 Box::new(MockTopicManager::admitting(vec![0])),
9259 );
9260 Engine::new(
9261 managers,
9262 HashMap::new(),
9263 Box::new(MemoryStorage::new()),
9264 Some(Box::new(adv)),
9265 EngineConfig {
9266 hosting_url: Some("https://valid.example.com".to_string()),
9267 suppress_default_sync_advertisements: true,
9268 ..Default::default()
9269 },
9270 )
9271 }
9272
9273 #[tokio::test]
9274 async fn sync_report_counts_admitted_outputs_on_success() {
9275 let (adv, _) = TrackingAdvertiser::new();
9276 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9277 assert!(report.ok(), "clean run must report ok: {report:?}");
9278 assert_eq!(report.to_create, 1, "tm_test was missing");
9279 assert_eq!(
9280 report.admitted.get("tm_test"),
9281 Some(&1),
9282 "the STEAK's admitted count must be surfaced: {report:?}"
9283 );
9284 }
9285
9286 #[tokio::test]
9287 async fn sync_report_surfaces_local_submit_failure() {
9288 // The TaggedBEEF comes back tagged for a topic with no registered
9289 // manager → the LOCAL submit fails. That failure must be in the
9290 // report, not swallowed (the live silent-failure shape).
9291 let (mut adv, _) = TrackingAdvertiser::new();
9292 adv.tag_topics = vec!["tm_unregistered".to_string()];
9293 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9294 assert!(!report.ok(), "a failed local submit must not report ok");
9295 assert_eq!(report.to_create, 1);
9296 let submit_error = report.submit_error.as_deref().unwrap_or_default();
9297 assert!(
9298 submit_error.contains("tm_unregistered"),
9299 "verbatim engine error expected, got: {submit_error}"
9300 );
9301 assert!(report.admitted.is_empty());
9302 }
9303
9304 #[tokio::test]
9305 async fn sync_report_surfaces_create_failure() {
9306 let (mut adv, _) = TrackingAdvertiser::new();
9307 adv.create_fails = true;
9308 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9309 assert!(!report.ok());
9310 assert!(
9311 report
9312 .create_error
9313 .as_deref()
9314 .unwrap_or_default()
9315 .contains("mock create refused"),
9316 "{report:?}"
9317 );
9318 assert!(report.submit_error.is_none(), "create failed before submit");
9319 }
9320
9321 #[tokio::test]
9322 async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
9323 // A node whose own storage already lists its ads must attempt ZERO
9324 // creates — the convergence direction of #320 defect 3 (live, the
9325 // missing self-admission made every cycle re-create all 17 ads).
9326 let (mut adv, created) = TrackingAdvertiser::new();
9327 adv.existing_ship = vec![Advertisement {
9328 protocol: Protocol::Ship,
9329 identity_key: "02aa".to_string(),
9330 domain: "https://valid.example.com".to_string(),
9331 topic_or_service: "tm_test".to_string(),
9332 beef: None,
9333 output_index: None,
9334 }];
9335 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9336 assert!(report.ok(), "{report:?}");
9337 assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
9338 assert!(created.lock().unwrap().is_empty());
9339 assert!(
9340 report.effective(),
9341 "converged-noop is the one zero-admit shape that IS success"
9342 );
9343 }
9344
9345 #[tokio::test]
9346 async fn sync_report_zero_admit_is_ok_but_not_effective() {
9347 // #320 M1: the local submit SUCCEEDS but the topic manager admits
9348 // ZERO outputs — a non-error refusal. The ads never enter our own
9349 // ls_ship/ls_slap, so the diff re-creates (re-pays) the full set
9350 // next cycle. `ok()` (errors-only) stays true; `effective()` must
9351 // refuse, and the route keys success on `effective()`.
9352 let (adv, _) = TrackingAdvertiser::new();
9353 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9354 managers.insert(
9355 "tm_test".to_string(),
9356 Box::new(MockTopicManager::admitting(vec![])), // refuses everything
9357 );
9358 let engine = Engine::new(
9359 managers,
9360 HashMap::new(),
9361 Box::new(MemoryStorage::new()),
9362 Some(Box::new(adv)),
9363 EngineConfig {
9364 hosting_url: Some("https://valid.example.com".to_string()),
9365 suppress_default_sync_advertisements: true,
9366 ..Default::default()
9367 },
9368 );
9369 let report = engine.sync_advertisements().await.unwrap();
9370 assert!(report.ok(), "no ERROR occurred: {report:?}");
9371 assert_eq!(report.to_create, 1);
9372 assert_eq!(report.admitted_total(), 0, "{report:?}");
9373 assert!(
9374 !report.effective(),
9375 "zero admitted on a non-empty create must not read as success: {report:?}"
9376 );
9377 }
9378
9379 #[tokio::test]
9380 async fn sync_report_partial_admit_is_not_effective() {
9381 // Delta D1: TWO topics in one submitted tx, per-topic admission
9382 // independent — tm_test admits its output, tm_refuse refuses all.
9383 // admitted_total (1) > 0 but < to_create (2): the refused ads
9384 // would be re-created (re-paid) every cycle, so this must NOT
9385 // read as success.
9386 let (mut adv, _) = TrackingAdvertiser::new();
9387 adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
9388 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9389 managers.insert(
9390 "tm_test".to_string(),
9391 Box::new(MockTopicManager::admitting(vec![0])),
9392 );
9393 managers.insert(
9394 "tm_refuse".to_string(),
9395 Box::new(MockTopicManager::admitting(vec![])), // refuses everything
9396 );
9397 let engine = Engine::new(
9398 managers,
9399 HashMap::new(),
9400 Box::new(MemoryStorage::new()),
9401 Some(Box::new(adv)),
9402 EngineConfig {
9403 hosting_url: Some("https://valid.example.com".to_string()),
9404 suppress_default_sync_advertisements: true,
9405 ..Default::default()
9406 },
9407 );
9408 let report = engine.sync_advertisements().await.unwrap();
9409 assert!(report.ok(), "no ERROR occurred: {report:?}");
9410 assert_eq!(report.to_create, 2, "{report:?}");
9411 assert_eq!(report.admitted_total(), 1, "{report:?}");
9412 assert!(
9413 !report.effective(),
9414 "a partial admit must not read as success: {report:?}"
9415 );
9416 }
9417
9418 #[tokio::test]
9419 async fn sync_report_lookup_failure_refuses_creation() {
9420 // #320 M2: a transient read failure must never become "current ads
9421 // = none" and re-create (re-pay) the whole set — creation is
9422 // REFUSED and the failure lands in the report.
9423 let (mut adv, created) = TrackingAdvertiser::new();
9424 adv.find_fails = true;
9425 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9426 assert!(!report.ok(), "{report:?}");
9427 assert!(!report.effective());
9428 assert!(
9429 report
9430 .lookup_error
9431 .as_deref()
9432 .unwrap_or_default()
9433 .contains("mock lookup refused"),
9434 "{report:?}"
9435 );
9436 assert_eq!(report.to_create, 0, "creation refused, not attempted");
9437 assert!(
9438 created.lock().unwrap().is_empty(),
9439 "no create call on a blind read"
9440 );
9441 assert!(report.submit_error.is_none());
9442 }
9443
9444 #[tokio::test]
9445 async fn sync_report_counts_skipped_revocations() {
9446 // #320 L2: `to_revoke: N` with a v1 no-op advertiser must be
9447 // distinguishable from a revoke that actually ran.
9448 let (mut adv, _) = TrackingAdvertiser::new();
9449 adv.revoke_empty = true;
9450 adv.existing_ship = vec![
9451 Advertisement {
9452 protocol: Protocol::Ship,
9453 identity_key: "02aa".to_string(),
9454 domain: "https://valid.example.com".to_string(),
9455 topic_or_service: "tm_test".to_string(),
9456 beef: None,
9457 output_index: None,
9458 },
9459 Advertisement {
9460 protocol: Protocol::Ship,
9461 identity_key: "02aa".to_string(),
9462 domain: "https://valid.example.com".to_string(),
9463 topic_or_service: "tm_stale".to_string(), // no longer configured
9464 beef: None,
9465 output_index: None,
9466 },
9467 ];
9468 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9469 assert!(report.ok(), "{report:?}");
9470 assert_eq!(report.to_revoke, 1, "tm_stale is stale");
9471 assert_eq!(
9472 report.revoke_skipped, 1,
9473 "the v1 no-op must be visible, not silent: {report:?}"
9474 );
9475 assert!(report.revoke_error.is_none());
9476 assert!(report.revoke_submit_error.is_none());
9477 }
9478
9479 // ── complete_missing_proofs (#130) ─────────────────────────────────
9480
9481 use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
9482 use std::cell::Cell;
9483
9484 /// A single raw mainnet tx (no proof) — used to build a proofless BEEF.
9485 const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
9486
9487 /// Build a proofless single-tx BEEF + return `(beef_bytes, txid)`.
9488 fn proofless_beef() -> (Vec<u8>, String) {
9489 use bsv_rs::transaction::{Beef, Transaction};
9490 let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
9491 let txid = tx.id();
9492 let mut beef = Beef::new();
9493 beef.merge_transaction(tx);
9494 (beef.to_binary(), txid)
9495 }
9496
9497 // ── #284: stitching a proof must never rebuild the stored BEEF ─────────
9498
9499 /// The corruption shape observed live on rust-beta: a stored BEEF whose
9500 /// SUBJECT is not the last tx in wire order. The old rewrite went through
9501 /// `Transaction::from_beef(bytes, None)`, whose None arm picks
9502 /// `txs.last()` — the PARENT here — and `to_beef(true)` then serialized the
9503 /// parent's world, dropping the subject from its own row. TEST_BEEF_HEX is
9504 /// exactly a two-tx chain (parent + child spending it), so re-encoding it
9505 /// child-first reproduces the trigger byte-for-byte.
9506 fn subject_not_last_beef() -> (Vec<u8>, String, String) {
9507 use bsv_rs::transaction::Beef;
9508 let parsed = Beef::from_binary(&test_beef()).unwrap();
9509 let parent = parsed.txs[0].tx().unwrap().clone();
9510 let child = parsed.txs[1].tx().unwrap().clone();
9511 let (parent_id, child_id) = (parent.id(), child.id());
9512 assert_ne!(parent_id, child_id);
9513
9514 // Serialize via to_writer, NOT to_binary: to_binary re-sorts
9515 // parents-first (subject last), which is precisely why bsv_rs's own
9516 // output never triggers the bug. The corrupt rows held WALLET
9517 // serialized bytes stored verbatim at admit time, whose wire order is
9518 // whatever the wallet emitted — model that by writing the child first.
9519 let mut beef = Beef::new();
9520 beef.merge_transaction(child); // subject FIRST — the trigger
9521 beef.merge_transaction(parent);
9522 let mut w = bsv_rs::primitives::encoding::Writer::new();
9523 beef.to_writer(&mut w);
9524 let bytes = w.into_bytes();
9525
9526 // The premise the whole test rests on: wire order preserved, subject
9527 // genuinely not last.
9528 let reread = Beef::from_binary(&bytes).unwrap();
9529 assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
9530 (bytes, child_id, parent_id)
9531 }
9532
9533 #[test]
9534 fn stitch_preserves_the_subject_when_it_is_not_last() {
9535 use bsv_rs::transaction::{Beef, MerklePath};
9536 let (stored, child_id, parent_id) = subject_not_last_beef();
9537 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
9538
9539 let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
9540 .expect("stitch must succeed on a beef that contains the txid");
9541
9542 let after = Beef::from_binary(&out).unwrap();
9543 assert!(
9544 after.find_txid(&child_id).is_some(),
9545 "the SUBJECT must survive its own proof-completion"
9546 );
9547 assert!(
9548 after.find_txid(&parent_id).is_some(),
9549 "the ancestry must survive too"
9550 );
9551 assert!(
9552 after.find_bump(&child_id).is_some(),
9553 "the stitched bump must prove the subject"
9554 );
9555 assert!(
9556 after.find_bump(&parent_id).is_none(),
9557 "the parent must NOT be claimed by the child's bump"
9558 );
9559 }
9560
9561 /// The old path, reproduced against the same input, to pin WHY the new one
9562 /// exists: it demonstrably drops the subject. If bsv_rs ever changes
9563 /// `from_beef(_, None)` to honor the subject, this starts failing and the
9564 /// comment trail can be revisited — until then it documents the hazard.
9565 #[test]
9566 fn the_old_round_trip_demonstrably_dropped_the_subject() {
9567 use bsv_rs::transaction::{Beef, Transaction};
9568 let (stored, child_id, _parent_id) = subject_not_last_beef();
9569 let tx = Transaction::from_beef(&stored, None).unwrap();
9570 assert_ne!(
9571 tx.id(),
9572 child_id,
9573 "from_beef(None) picks txs.last(), not the subject — the trigger"
9574 );
9575 let rebuilt = tx.to_beef(true).unwrap();
9576 let reparsed = Beef::from_binary(&rebuilt).unwrap();
9577 assert!(
9578 reparsed.find_txid(&child_id).is_none(),
9579 "the rebuild loses the subject — exactly the #284 artifact"
9580 );
9581 }
9582
9583 /// bsv-low M19 R2 round 3 (review HIGH-1): a stored BEEF whose SUBJECT
9584 /// sits on a stale (orphan) bump at height H, re-stitched with a
9585 /// same-height DIFFERENT-root proof, must serialize with the subject on
9586 /// the NEW bump and the orphan bump dropped — otherwise the hop leg
9587 /// churns and re-latches the orphan proof forever.
9588 #[test]
9589 fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
9590 use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
9591 // subject spends a real parent; both are raw in the BEEF
9592 let parent = Transaction::from_hex(RAW_PARENT).unwrap();
9593 let parent_id = parent.id();
9594 let subject = child_of(&parent_id, 0);
9595 let subject_id = subject.id();
9596 // the ORPHAN bump: a two-leaf block at H so its root is NOT the txid,
9597 // and it differs from the canonical block's root
9598 let orphan = MerklePath::new_unchecked(
9599 965_771,
9600 vec![vec![
9601 MerklePathLeaf::new_txid(0, subject_id.clone()),
9602 MerklePathLeaf::new(1, "aa".repeat(32)),
9603 ]],
9604 )
9605 .unwrap();
9606 let mut beef = Beef::new();
9607 let bi = beef.merge_bump(orphan.clone());
9608 beef.merge_raw_tx(parent.to_binary(), None);
9609 beef.merge_raw_tx(subject.to_binary(), Some(bi));
9610 let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
9611 let stored = beef.to_binary();
9612 assert_eq!(
9613 Beef::from_binary(&stored)
9614 .unwrap()
9615 .find_bump(&subject_id)
9616 .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
9617 Some(orphan_root.clone())
9618 );
9619 // the canonical proof at the SAME height, a DIFFERENT root
9620 let canonical = MerklePath::new_unchecked(
9621 965_771,
9622 vec![vec![
9623 MerklePathLeaf::new_txid(0, subject_id.clone()),
9624 MerklePathLeaf::new(1, "bb".repeat(32)),
9625 ]],
9626 )
9627 .unwrap();
9628 let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
9629 assert_ne!(
9630 canonical_root, orphan_root,
9631 "the reorg shape: same height, new root"
9632 );
9633 let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
9634 let after = Beef::from_binary(&out).unwrap();
9635 // the subject's OWN bump is the canonical one
9636 let own = after
9637 .find_txid(&subject_id)
9638 .and_then(BeefTx::bump_index)
9639 .and_then(|bi| after.bumps.get(bi))
9640 .unwrap();
9641 assert_eq!(
9642 own.compute_root(Some(&subject_id)).unwrap(),
9643 canonical_root,
9644 "the subject re-anchored to the new bump"
9645 );
9646 // the orphan bump is gone (only the canonical one remains)
9647 assert_eq!(
9648 after.bumps.len(),
9649 1,
9650 "the unreferenced orphan bump was dropped"
9651 );
9652 // and a reader that uses the tx's OWN bump sees canonical, never orphan
9653 assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
9654 }
9655
9656 #[test]
9657 fn stitch_refuses_a_beef_that_lacks_the_txid() {
9658 use bsv_rs::transaction::MerklePath;
9659 let (stored, _c, _p) = subject_not_last_beef();
9660 let foreign = "aa".repeat(32);
9661 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
9662 assert!(
9663 Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
9664 "no txid in the beef -> no write, never garbage"
9665 );
9666 }
9667
9668 /// A real mainnet raw tx, the unproven parent for the re-anchor pin.
9669 const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
9670
9671 /// A minimal raw tx spending `source:vout` with one OP_TRUE output.
9672 fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
9673 let mut sx = String::from("0100000001");
9674 let mut prev = hex::decode(source_txid).unwrap();
9675 prev.reverse();
9676 sx.push_str(&hex::encode(prev));
9677 sx.push_str(&hex::encode(vout.to_le_bytes()));
9678 sx.push_str("00ffffffff01");
9679 sx.push_str(&hex::encode(1000u64.to_le_bytes()));
9680 sx.push_str("015100000000");
9681 bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
9682 }
9683
9684 /// A minimal valid single-leaf BUMP hex proving `txid` at `height`.
9685 fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
9686 use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
9687 let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
9688 MerklePath::new_unchecked(height, vec![vec![leaf]])
9689 .unwrap()
9690 .to_hex()
9691 }
9692
9693 /// Mock fetcher: returns a configured proof (or none) for any txid, and
9694 /// counts how many times it was asked.
9695 struct StubFetcher {
9696 proof: Option<String>,
9697 calls: Cell<u32>,
9698 }
9699
9700 #[async_trait(?Send)]
9701 impl AncestorFetcher for StubFetcher {
9702 async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
9703 self.calls.set(self.calls.get() + 1);
9704 Ok(FetchedAncestor {
9705 raw_tx: PROOFLESS_RAW_TX.to_string(),
9706 proof: self.proof.clone(),
9707 })
9708 }
9709
9710 /// Models a fetcher whose header source CONFIRMS a stored bump — so the
9711 /// already-structurally-proven re-verify gate (the window-clog path)
9712 /// latches without a re-fetch, exactly as before the #192/#193 hardening.
9713 /// (`verified_proof_for` is left at its default: it delegates to
9714 /// `fetch_ancestor` above, so the `calls` counter still tracks fetches.)
9715 async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
9716 true
9717 }
9718 }
9719
9720 /// Build an engine over a storage holding one proofless output, with an
9721 /// optional ancestor fetcher.
9722 async fn engine_with_proofless_output(
9723 fetcher: Option<std::rc::Rc<StubFetcher>>,
9724 ) -> (Engine, String) {
9725 let (beef, txid) = proofless_beef();
9726 let storage = MemoryStorage::new();
9727 storage
9728 .insert_output(&Output {
9729 txid: txid.clone(),
9730 output_index: 0,
9731 output_script: vec![0x76],
9732 satoshis: 1_000_000_000,
9733 topic: "Hello".to_string(),
9734 spent: false,
9735 outputs_consumed: vec![],
9736 consumed_by: vec![],
9737 beef: Some(beef),
9738 block_height: None,
9739 score: Some(1.0),
9740 })
9741 .await
9742 .unwrap();
9743
9744 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9745 managers.insert(
9746 "Hello".into(),
9747 Box::new(MockTopicManager::admitting(vec![0])),
9748 );
9749 let mut engine = Engine::new(
9750 managers,
9751 HashMap::new(),
9752 Box::new(storage),
9753 None,
9754 EngineConfig::default(),
9755 );
9756 if let Some(f) = fetcher {
9757 engine.set_ancestor_fetcher(f);
9758 }
9759 (engine, txid)
9760 }
9761
9762 #[tokio::test]
9763 async fn complete_missing_proofs_noop_without_fetcher() {
9764 // No ancestor fetcher configured → pure no-op (production default).
9765 let (engine, _txid) = engine_with_proofless_output(None).await;
9766 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
9767 assert_eq!(summary, ProofCompletionSummary::default());
9768 }
9769
9770 #[tokio::test]
9771 async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
9772 let (_beef, txid) = proofless_beef();
9773 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
9774 let fetcher = std::rc::Rc::new(StubFetcher {
9775 proof: Some(proof_hex),
9776 calls: Cell::new(0),
9777 });
9778 let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
9779
9780 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
9781 assert_eq!(summary.scanned, 1);
9782 assert_eq!(summary.proofless, 1);
9783 assert_eq!(
9784 summary.completed, 1,
9785 "the proofless BEEF should be completed"
9786 );
9787 assert_eq!(fetcher.calls.get(), 1);
9788
9789 // The stored BEEF now carries a proof for the target tx + the output
9790 // got its block height.
9791 let out = engine
9792 .storage()
9793 .find_output(&txid, 0, Some("Hello"), None, true)
9794 .await
9795 .unwrap()
9796 .unwrap();
9797 assert_eq!(out.block_height, Some(850_000));
9798 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
9799 assert!(
9800 beef.find_txid(&txid)
9801 .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
9802 "completed BEEF must prove the target tx"
9803 );
9804
9805 // A second pass is a no-op: the tx is now proven, so it is not counted
9806 // proofless and the fetcher is not asked again.
9807 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
9808 assert_eq!(summary2.proofless, 0);
9809 assert_eq!(summary2.completed, 0);
9810 assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
9811 }
9812
9813 #[tokio::test]
9814 async fn complete_missing_proofs_skips_unconfirmed() {
9815 // Fetcher reachable but the tx is unmined (no BUMP) → skipped, not
9816 // errored, and counted still_unconfirmed for retry.
9817 let fetcher = std::rc::Rc::new(StubFetcher {
9818 proof: None,
9819 calls: Cell::new(0),
9820 });
9821 let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
9822 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
9823 assert_eq!(summary.proofless, 1);
9824 assert_eq!(summary.completed, 0);
9825 assert_eq!(summary.still_unconfirmed, 1);
9826 assert_eq!(fetcher.calls.get(), 1);
9827 }
9828
9829 /// Build an engine like [`engine_with_proofless_output`] but keep a shared
9830 /// handle to the `MemoryStorage` (via the `Rc<T: Storage>` delegation) so
9831 /// the test can advance its deterministic clock — the push-primary
9832 /// backstop age-gate tests (#228) need to age rows without sleeping.
9833 async fn engine_with_proofless_output_shared(
9834 fetcher: Option<std::rc::Rc<StubFetcher>>,
9835 ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
9836 let (beef, txid) = proofless_beef();
9837 let storage = std::rc::Rc::new(MemoryStorage::new());
9838 storage
9839 .insert_output(&Output {
9840 txid: txid.clone(),
9841 output_index: 0,
9842 output_script: vec![0x76],
9843 satoshis: 1_000_000_000,
9844 topic: "Hello".to_string(),
9845 spent: false,
9846 outputs_consumed: vec![],
9847 consumed_by: vec![],
9848 beef: Some(beef),
9849 block_height: None,
9850 score: Some(1.0),
9851 })
9852 .await
9853 .unwrap();
9854
9855 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9856 managers.insert(
9857 "Hello".into(),
9858 Box::new(MockTopicManager::admitting(vec![0])),
9859 );
9860 let mut engine = Engine::new(
9861 managers,
9862 HashMap::new(),
9863 Box::new(storage.clone()),
9864 None,
9865 EngineConfig::default(),
9866 );
9867 if let Some(f) = fetcher {
9868 engine.set_ancestor_fetcher(f);
9869 }
9870 (engine, txid, storage)
9871 }
9872
9873 #[tokio::test]
9874 async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
9875 // Push-primary backstop (#228): a FRESHLY stored proofless tx must NOT
9876 // be polled — its proof is expected via /arc-ingest at push speed. The
9877 // gated pass scans nothing and never asks the fetcher.
9878 let proof_hex = {
9879 let (_beef, txid) = proofless_beef();
9880 single_leaf_bump_hex(&txid, 850_000)
9881 };
9882 let fetcher = std::rc::Rc::new(StubFetcher {
9883 proof: Some(proof_hex),
9884 calls: Cell::new(0),
9885 });
9886 let (engine, _txid, storage) =
9887 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
9888
9889 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
9890 assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
9891 assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
9892
9893 // WEBHOOK-OUTAGE DEGRADATION: no push ever arrives. Once the row is
9894 // older than the gate, the SAME backstop pass polls and completes it
9895 // exactly as the pre-#228 behaviour — degradation is to polling,
9896 // never to nothing.
9897 storage.advance_clock(1800);
9898 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
9899 assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
9900 assert_eq!(summary.completed, 1, "the backstop completes the proof");
9901 assert_eq!(fetcher.calls.get(), 1);
9902 }
9903
9904 #[tokio::test]
9905 async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
9906 // Pushed-proof-then-chaser-skips (#228): a proof arriving via the real
9907 // /arc-ingest producer path (`handle_new_merkle_proof` → the
9908 // `update_transaction_beef` stitch, which latches `has_proof`) drops
9909 // the tx out of the poll candidate set entirely — even after the age
9910 // gate opens, the chaser never asks the fetcher about it.
9911 let fetcher = std::rc::Rc::new(StubFetcher {
9912 proof: None,
9913 calls: Cell::new(0),
9914 });
9915 let (engine, txid, storage) =
9916 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
9917
9918 // The push lands (arc-ingest calls exactly this after its own
9919 // chaintracks verify).
9920 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
9921 engine
9922 .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
9923 .await
9924 .unwrap();
9925
9926 // Age the row well past the backstop gate: still skipped — the pushed
9927 // proof, not the age, is what removes it from the chaser's world.
9928 storage.advance_clock(1_000_000);
9929 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
9930 assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
9931 assert_eq!(summary.completed, 0);
9932 assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
9933 }
9934
9935 #[tokio::test]
9936 async fn complete_missing_proofs_respects_limit() {
9937 // Two proofless txs, limit 1 → only one row is scanned this tick.
9938 let (beef1, txid1) = proofless_beef();
9939 // A second distinct proofless tx (different raw tx → different txid).
9940 let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
9941 let (beef2, txid2) = {
9942 use bsv_rs::transaction::{Beef, Transaction};
9943 let tx = Transaction::from_hex(raw2).unwrap();
9944 let id = tx.id();
9945 let mut b = Beef::new();
9946 b.merge_transaction(tx);
9947 (b.to_binary(), id)
9948 };
9949
9950 let storage = MemoryStorage::new();
9951 for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
9952 storage
9953 .insert_output(&Output {
9954 txid: txid.clone(),
9955 output_index: 0,
9956 output_script: vec![0x76],
9957 satoshis: 1000,
9958 topic: "Hello".to_string(),
9959 spent: false,
9960 outputs_consumed: vec![],
9961 consumed_by: vec![],
9962 beef: Some(beef),
9963 block_height: None,
9964 score: Some(1.0),
9965 })
9966 .await
9967 .unwrap();
9968 }
9969
9970 let fetcher = std::rc::Rc::new(StubFetcher {
9971 proof: None,
9972 calls: Cell::new(0),
9973 });
9974 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
9975 managers.insert(
9976 "Hello".into(),
9977 Box::new(MockTopicManager::admitting(vec![0])),
9978 );
9979 let mut engine = Engine::new(
9980 managers,
9981 HashMap::new(),
9982 Box::new(storage),
9983 None,
9984 EngineConfig::default(),
9985 );
9986 engine.set_ancestor_fetcher(fetcher.clone());
9987
9988 let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
9989 assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
9990 }
9991
9992 #[tokio::test]
9993 async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
9994 // Window-clog regression (#130): migration 0010 defaulted EVERY
9995 // existing `transactions` row to has_proof = 0, including rows whose
9996 // stored BEEF already carries a proof (e.g. GASP-synced txs). The
9997 // candidate query (`WHERE has_proof = 0 LIMIT n`) returns those rows
9998 // every tick; the engine skips them but — before this fix — never
9999 // flipped the flag, so they lingered in the bounded window forever and
10000 // starved genuinely-proofless rows behind them. The fix marks each
10001 // already-proven scanned row so it drops out of the NEXT pass.
10002 use bsv_rs::transaction::{Beef, MerklePath, Transaction};
10003
10004 let storage = MemoryStorage::new();
10005
10006 // Seed several ALREADY-PROVEN rows. They are inserted via
10007 // `insert_output`, which (modeling migration 0010's default) does NOT
10008 // set the proof flag — so despite carrying a valid proof they show up
10009 // as has_proof = 0 candidates: the exact clog.
10010 let proven_raws = [
10011 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
10012 "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
10013 ];
10014 let mut proven_ids = Vec::new();
10015 for raw in proven_raws {
10016 let mut tx = Transaction::from_hex(raw).unwrap();
10017 let id = tx.id();
10018 tx.merkle_path =
10019 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
10020 // to_beef(true) carries the merkle proof into the BEEF, so the row
10021 // genuinely IS proven — yet its flag stays 0 (insert path).
10022 let proven_beef = tx.to_beef(true).unwrap();
10023 assert_eq!(
10024 Beef::from_binary(&proven_beef)
10025 .unwrap()
10026 .find_txid(&id)
10027 .map(bsv_rs::transaction::BeefTx::has_proof),
10028 Some(true),
10029 "proven fixture row must carry its proof"
10030 );
10031 proven_ids.push(id.clone());
10032 storage
10033 .insert_output(&Output {
10034 txid: id,
10035 output_index: 0,
10036 output_script: vec![0x76],
10037 satoshis: 1000,
10038 topic: "Hello".to_string(),
10039 spent: false,
10040 outputs_consumed: vec![],
10041 consumed_by: vec![],
10042 beef: Some(proven_beef),
10043 block_height: Some(800_000),
10044 score: Some(1.0),
10045 })
10046 .await
10047 .unwrap();
10048 }
10049
10050 let fetcher = std::rc::Rc::new(StubFetcher {
10051 proof: None,
10052 calls: Cell::new(0),
10053 });
10054 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10055 managers.insert(
10056 "Hello".into(),
10057 Box::new(MockTopicManager::admitting(vec![0])),
10058 );
10059 let mut engine = Engine::new(
10060 managers,
10061 HashMap::new(),
10062 Box::new(storage),
10063 None,
10064 EngineConfig::default(),
10065 );
10066 engine.set_ancestor_fetcher(fetcher.clone());
10067
10068 // First pass over a window covering both proven rows: each is detected
10069 // already-proven and marked. Nothing proofless, nothing fetched.
10070 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10071 assert_eq!(summary.scanned, proven_ids.len());
10072 assert_eq!(summary.proofless, 0);
10073 assert_eq!(summary.completed, 0);
10074 assert_eq!(
10075 summary.already_proven,
10076 proven_ids.len(),
10077 "every already-proven row is marked this pass"
10078 );
10079 assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
10080
10081 // Window has advanced: the NEXT candidate query no longer returns the
10082 // marked rows, so the pass scans nothing.
10083 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
10084 assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
10085 assert_eq!(summary2.already_proven, 0);
10086 }
10087
10088 #[tokio::test]
10089 async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
10090 // The window-clog in action with a small limit: a genuinely-proofless
10091 // backlog row sits behind already-proven (stale-flag) rows. Each tick
10092 // marks the proven rows it scans (dropping them out), so over a few
10093 // bounded ticks the window advances until the proofless tx is reached
10094 // + completed — instead of being starved forever.
10095 use bsv_rs::transaction::{MerklePath, Transaction};
10096
10097 let storage = MemoryStorage::new();
10098
10099 let proven_raws = [
10100 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
10101 ];
10102 for raw in proven_raws {
10103 let mut tx = Transaction::from_hex(raw).unwrap();
10104 let id = tx.id();
10105 tx.merkle_path =
10106 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
10107 let proven_beef = tx.to_beef(true).unwrap();
10108 storage
10109 .insert_output(&Output {
10110 txid: id,
10111 output_index: 0,
10112 output_script: vec![0x76],
10113 satoshis: 1000,
10114 topic: "Hello".to_string(),
10115 spent: false,
10116 outputs_consumed: vec![],
10117 consumed_by: vec![],
10118 beef: Some(proven_beef),
10119 block_height: Some(800_000),
10120 score: Some(1.0),
10121 })
10122 .await
10123 .unwrap();
10124 }
10125
10126 // The historical proofless tx (the f584f846-style backlog row).
10127 let (proofless, proofless_txid) = proofless_beef();
10128 storage
10129 .insert_output(&Output {
10130 txid: proofless_txid.clone(),
10131 output_index: 0,
10132 output_script: vec![0x76],
10133 satoshis: 1000,
10134 topic: "Hello".to_string(),
10135 spent: false,
10136 outputs_consumed: vec![],
10137 consumed_by: vec![],
10138 beef: Some(proofless),
10139 block_height: None,
10140 score: Some(1.0),
10141 })
10142 .await
10143 .unwrap();
10144
10145 let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
10146 let fetcher = std::rc::Rc::new(StubFetcher {
10147 proof: Some(proof_hex),
10148 calls: Cell::new(0),
10149 });
10150 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10151 managers.insert(
10152 "Hello".into(),
10153 Box::new(MockTopicManager::admitting(vec![0])),
10154 );
10155 let mut engine = Engine::new(
10156 managers,
10157 HashMap::new(),
10158 Box::new(storage),
10159 None,
10160 EngineConfig::default(),
10161 );
10162 engine.set_ancestor_fetcher(fetcher.clone());
10163
10164 // Run bounded ticks (limit 1) until the proofless tx is completed. With
10165 // 2 rows total a couple of ticks suffice; cap iterations defensively.
10166 let mut completed_total = 0;
10167 for _ in 0..5 {
10168 let s = engine.complete_missing_proofs(1, 0).await.unwrap();
10169 completed_total += s.completed;
10170 if completed_total > 0 {
10171 break;
10172 }
10173 }
10174 assert_eq!(
10175 completed_total, 1,
10176 "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
10177 );
10178
10179 let out = engine
10180 .storage()
10181 .find_output(&proofless_txid, 0, Some("Hello"), None, true)
10182 .await
10183 .unwrap()
10184 .unwrap();
10185 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
10186 assert_eq!(
10187 beef.find_txid(&proofless_txid)
10188 .map(bsv_rs::transaction::BeefTx::has_proof),
10189 Some(true),
10190 "the old proofless tx now carries its proof"
10191 );
10192 }
10193
10194 // ── S2 queue-durable admission (bsv-low 2026-08-29): Phase-3 faults ──
10195 //
10196 // The phantom class: under a D1 storm `submit` returned Ok(steak) while
10197 // its writes failed — the ack outlived the admission. These pins hold
10198 // the new contract: a fault is REPORTED, the faulted topic is NOT
10199 // recorded as applied, and a replay of the same bytes re-applies.
10200
10201 use crate::storage::StorageError;
10202 use std::rc::Rc;
10203
10204 /// `MemoryStorage` behind two fault switches. Every method delegates;
10205 /// the two the tests flip model a D1 write fault (`insert_output`) and
10206 /// a D1 read fault (`find_output` — the validation's previous-coin scan
10207 /// AND Phase 3's stale/consumed lookups).
10208 struct FaultingStorage {
10209 inner: MemoryStorage,
10210 fail_insert_output: Cell<bool>,
10211 fail_find_output: Cell<bool>,
10212 }
10213
10214 impl FaultingStorage {
10215 fn new() -> Rc<Self> {
10216 Rc::new(Self {
10217 inner: MemoryStorage::new(),
10218 fail_insert_output: Cell::new(false),
10219 fail_find_output: Cell::new(false),
10220 })
10221 }
10222 }
10223
10224 #[async_trait(?Send)]
10225 impl Storage for FaultingStorage {
10226 async fn put_deferred_graph(
10227 &self,
10228 record: &crate::gasp::DeferredGraph,
10229 ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
10230 self.inner.put_deferred_graph(record).await
10231 }
10232 async fn find_deferred_graphs(
10233 &self,
10234 host: &str,
10235 topic: &str,
10236 ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
10237 self.inner.find_deferred_graphs(host, topic).await
10238 }
10239 async fn get_deferred_graph(
10240 &self,
10241 host: &str,
10242 topic: &str,
10243 outpoint: &str,
10244 ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
10245 self.inner.get_deferred_graph(host, topic, outpoint).await
10246 }
10247 async fn delete_deferred_graph(
10248 &self,
10249 host: &str,
10250 topic: &str,
10251 outpoint: &str,
10252 ) -> Result<(), StorageError> {
10253 self.inner
10254 .delete_deferred_graph(host, topic, outpoint)
10255 .await
10256 }
10257 async fn record_peer_sync_outcome(
10258 &self,
10259 host: &str,
10260 topic: &str,
10261 success: bool,
10262 ) -> Result<(), StorageError> {
10263 self.inner
10264 .record_peer_sync_outcome(host, topic, success)
10265 .await
10266 }
10267 async fn get_peer_sync_health(
10268 &self,
10269 host: &str,
10270 topic: &str,
10271 ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
10272 self.inner.get_peer_sync_health(host, topic).await
10273 }
10274 async fn record_peer_sync_yield(
10275 &self,
10276 host: &str,
10277 topic: &str,
10278 yielded: bool,
10279 ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
10280 self.inner
10281 .record_peer_sync_yield(host, topic, yielded)
10282 .await
10283 }
10284 async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
10285 if self.fail_insert_output.get() {
10286 return Err(StorageError::Database(
10287 "D1_ERROR: storage overloaded".into(),
10288 ));
10289 }
10290 self.inner.insert_output(output).await
10291 }
10292 async fn delete_output(
10293 &self,
10294 txid: &str,
10295 output_index: u32,
10296 topic: &str,
10297 ) -> Result<(), StorageError> {
10298 self.inner.delete_output(txid, output_index, topic).await
10299 }
10300 async fn mark_utxo_as_spent(
10301 &self,
10302 txid: &str,
10303 output_index: u32,
10304 topic: &str,
10305 ) -> Result<(), StorageError> {
10306 self.inner
10307 .mark_utxo_as_spent(txid, output_index, topic)
10308 .await
10309 }
10310 async fn update_consumed_by(
10311 &self,
10312 txid: &str,
10313 output_index: u32,
10314 topic: &str,
10315 consumed_by: &[Outpoint],
10316 ) -> Result<(), StorageError> {
10317 self.inner
10318 .update_consumed_by(txid, output_index, topic, consumed_by)
10319 .await
10320 }
10321 async fn update_transaction_beef(
10322 &self,
10323 txid: &str,
10324 beef: &[u8],
10325 ) -> Result<(), StorageError> {
10326 self.inner.update_transaction_beef(txid, beef).await
10327 }
10328 async fn insert_applied_transaction(
10329 &self,
10330 tx: &AppliedTransaction,
10331 ) -> Result<(), StorageError> {
10332 self.inner.insert_applied_transaction(tx).await
10333 }
10334 async fn does_applied_transaction_exist(
10335 &self,
10336 tx: &AppliedTransaction,
10337 ) -> Result<bool, StorageError> {
10338 self.inner.does_applied_transaction_exist(tx).await
10339 }
10340 async fn delete_applied_transaction(
10341 &self,
10342 tx: &AppliedTransaction,
10343 ) -> Result<(), StorageError> {
10344 self.inner.delete_applied_transaction(tx).await
10345 }
10346 async fn find_output(
10347 &self,
10348 txid: &str,
10349 output_index: u32,
10350 topic: Option<&str>,
10351 spent: Option<bool>,
10352 include_beef: bool,
10353 ) -> Result<Option<Output>, StorageError> {
10354 if self.fail_find_output.get() {
10355 return Err(StorageError::Database("D1_ERROR: read timed out".into()));
10356 }
10357 self.inner
10358 .find_output(txid, output_index, topic, spent, include_beef)
10359 .await
10360 }
10361 async fn find_outputs_for_transaction(
10362 &self,
10363 txid: &str,
10364 include_beef: bool,
10365 ) -> Result<Vec<Output>, StorageError> {
10366 self.inner
10367 .find_outputs_for_transaction(txid, include_beef)
10368 .await
10369 }
10370 async fn find_utxos_for_topic(
10371 &self,
10372 topic: &str,
10373 since: Option<f64>,
10374 limit: Option<u64>,
10375 include_beef: bool,
10376 ) -> Result<Vec<Output>, StorageError> {
10377 self.inner
10378 .find_utxos_for_topic(topic, since, limit, include_beef)
10379 .await
10380 }
10381 async fn update_last_interaction(
10382 &self,
10383 host: &str,
10384 topic: &str,
10385 since: u64,
10386 ) -> Result<(), StorageError> {
10387 self.inner.update_last_interaction(host, topic, since).await
10388 }
10389 async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
10390 self.inner.get_last_interaction(host, topic).await
10391 }
10392 }
10393
10394 fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
10395 let storage = FaultingStorage::new();
10396 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
10397 managers.insert(
10398 "tm_test".to_string(),
10399 Box::new(MockTopicManager::admitting(admit_indices)),
10400 );
10401 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
10402 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
10403 let engine = Engine::new(
10404 managers,
10405 lookup_services,
10406 Box::new(Rc::clone(&storage)),
10407 None,
10408 EngineConfig::default(),
10409 );
10410 (engine, storage)
10411 }
10412
10413 async fn applied(storage: &FaultingStorage) -> bool {
10414 storage
10415 .does_applied_transaction_exist(&AppliedTransaction {
10416 txid: TEST_TXID.to_string(),
10417 topic: "tm_test".to_string(),
10418 })
10419 .await
10420 .unwrap()
10421 }
10422
10423 /// The old behaviour, pinned: a clean submit is durable, records the
10424 /// topic as applied, and the output is in storage.
10425 #[tokio::test]
10426 async fn clean_submit_is_durable_and_records_applied() {
10427 let (engine, storage) = make_faulting_engine(vec![0]);
10428 let (steak, report) = engine
10429 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10430 .await
10431 .unwrap();
10432 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
10433 assert!(report.is_durable(), "{}", report.summary());
10434 assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
10435 assert!(applied(&storage).await);
10436 assert!(storage
10437 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
10438 .await
10439 .unwrap()
10440 .is_some());
10441 }
10442
10443 /// THE PHANTOM, red→green: the insert fails, the steak still says
10444 /// "admitted" (the decision stands), but the report says NOT DURABLE and
10445 /// names the site — and the topic is NOT recorded as applied, so nothing
10446 /// downstream can treat the loss as final.
10447 #[tokio::test]
10448 async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
10449 let (engine, storage) = make_faulting_engine(vec![0]);
10450 storage.fail_insert_output.set(true);
10451 let (steak, report) = engine
10452 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10453 .await
10454 .unwrap();
10455 assert_eq!(
10456 steak["tm_test"].outputs_to_admit,
10457 vec![0],
10458 "the admission DECISION is unchanged by a write fault"
10459 );
10460 assert!(!report.is_durable());
10461 assert_eq!(report.faults.len(), 1, "{}", report.summary());
10462 assert_eq!(report.faults[0].topic, "tm_test");
10463 assert_eq!(report.faults[0].site, "insert_output");
10464 assert!(report.faults[0].error.contains("D1_ERROR"));
10465 assert!(report.applied_topics.is_empty());
10466 assert!(
10467 !applied(&storage).await,
10468 "a faulted topic must not be recorded as applied — the replay would be a dupe"
10469 );
10470 assert!(storage
10471 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
10472 .await
10473 .unwrap()
10474 .is_none());
10475 }
10476
10477 /// The replay: same bytes, storage healthy again → re-validated (not a
10478 /// dupe), re-written, recorded. A THIRD submit is then the dupe it
10479 /// should be: nothing admitted, nothing to write, durable.
10480 #[tokio::test]
10481 async fn replay_after_a_fault_reapplies_and_records_applied() {
10482 let (engine, storage) = make_faulting_engine(vec![0]);
10483 storage.fail_insert_output.set(true);
10484 let (_, first) = engine
10485 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10486 .await
10487 .unwrap();
10488 assert!(!first.is_durable());
10489
10490 storage.fail_insert_output.set(false);
10491 let (steak, replay) = engine
10492 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10493 .await
10494 .unwrap();
10495 assert_eq!(
10496 steak["tm_test"].outputs_to_admit,
10497 vec![0],
10498 "re-validated, not deduped"
10499 );
10500 assert!(replay.is_durable(), "{}", replay.summary());
10501 assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
10502 assert!(applied(&storage).await);
10503 assert!(storage
10504 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
10505 .await
10506 .unwrap()
10507 .is_some());
10508
10509 let (steak, third) = engine
10510 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10511 .await
10512 .unwrap();
10513 assert!(
10514 steak["tm_test"].outputs_to_admit.is_empty(),
10515 "now a genuine dupe: nothing admitted"
10516 );
10517 assert!(third.is_durable() && third.applied_topics.is_empty());
10518 }
10519
10520 /// The SPEND half of the phantom: a `find_output` fault during
10521 /// validation made a settle look like it consumed nothing, so the spend
10522 /// pointer was never written and the ack stood. Now the read fault is
10523 /// reported, the topic stays unrecorded, the previously-admitted coin
10524 /// is untouched — and the healthy replay consumes it.
10525 #[tokio::test]
10526 async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
10527 let (engine, storage) = make_faulting_engine(vec![0]);
10528 // The subject's own input: seed the coin it spends as a previously
10529 // admitted tm_test output.
10530 let tx = Transaction::from_beef(&test_beef(), None).unwrap();
10531 let source_txid = tx.inputs[0].get_source_txid().unwrap();
10532 let source_vout = tx.inputs[0].source_output_index;
10533 storage
10534 .insert_output(&Output {
10535 txid: source_txid.clone(),
10536 output_index: source_vout,
10537 output_script: vec![0x51],
10538 satoshis: 1,
10539 topic: "tm_test".to_string(),
10540 spent: false,
10541 outputs_consumed: vec![],
10542 consumed_by: vec![],
10543 beef: None,
10544 block_height: None,
10545 score: Some(1.0),
10546 })
10547 .await
10548 .unwrap();
10549
10550 storage.fail_find_output.set(true);
10551 let (_, faulted) = engine
10552 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10553 .await
10554 .unwrap();
10555 assert!(!faulted.is_durable());
10556 assert!(
10557 faulted.faults.iter().any(|f| f.site == "find_output"),
10558 "{}",
10559 faulted.summary()
10560 );
10561 assert!(
10562 !applied(&storage).await,
10563 "unrecorded: the replay must re-read"
10564 );
10565 storage.fail_find_output.set(false);
10566 assert!(
10567 storage
10568 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
10569 .await
10570 .unwrap()
10571 .is_some(),
10572 "the coin the faulted pass could not see is still there, untouched"
10573 );
10574
10575 let (_, replay) = engine
10576 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10577 .await
10578 .unwrap();
10579 assert!(replay.is_durable(), "{}", replay.summary());
10580 assert!(applied(&storage).await);
10581 // The mock manager retains no coins, so the consumed coin is stale
10582 // and deleted — the observable proof the spend was PROCESSED.
10583 assert!(
10584 storage
10585 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
10586 .await
10587 .unwrap()
10588 .is_none(),
10589 "the healthy replay consumed the coin the faulted pass missed"
10590 );
10591 }
10592
10593 /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): a lookup service that missed
10594 /// its admit notification is told AGAIN from the engine's own stored
10595 /// outputs — one call per admitted output per service, the WholeTx body
10596 /// naming the subject — while `/submit` of the same bytes is deduped and
10597 /// tells nobody (the premise, RED-verified in the same test); a topic the
10598 /// engine never admitted this txid on yields 0 (the caller re-submits).
10599 #[tokio::test]
10600 async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
10601 use std::cell::RefCell;
10602 use std::rc::Rc;
10603
10604 struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
10605 #[async_trait(?Send)]
10606 impl LookupServiceTrait for CountingLs {
10607 fn admission_mode(&self) -> AdmissionMode {
10608 self.1
10609 }
10610 fn spend_notification_mode(&self) -> SpendNotificationMode {
10611 SpendNotificationMode::None
10612 }
10613 async fn output_admitted_by_topic(
10614 &self,
10615 payload: &OutputAdmittedByTopic,
10616 ) -> Result<(), LookupServiceError> {
10617 let rec = match payload {
10618 OutputAdmittedByTopic::LockingScript {
10619 txid,
10620 output_index,
10621 topic,
10622 ..
10623 } => (txid.clone(), *output_index, topic.clone()),
10624 OutputAdmittedByTopic::WholeTx {
10625 atomic_beef,
10626 output_index,
10627 topic,
10628 ..
10629 } => {
10630 let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
10631 (
10632 beef.atomic_txid
10633 .clone()
10634 .expect("the WholeTx body NAMES its subject"),
10635 *output_index,
10636 topic.clone(),
10637 )
10638 }
10639 };
10640 self.0.borrow_mut().push(rec);
10641 Ok(())
10642 }
10643 async fn output_evicted(
10644 &self,
10645 _txid: &str,
10646 _output_index: u32,
10647 ) -> Result<(), LookupServiceError> {
10648 Ok(())
10649 }
10650 async fn lookup(
10651 &self,
10652 _question: &LookupQuestion,
10653 ) -> Result<LookupResult, LookupServiceError> {
10654 Ok(LookupResult::OutputList(Vec::new()))
10655 }
10656 async fn get_documentation(&self) -> String {
10657 "counting".to_string()
10658 }
10659 async fn get_metadata(&self) -> ServiceMetadata {
10660 ServiceMetadata {
10661 name: "counting-ls".to_string(),
10662 ..Default::default()
10663 }
10664 }
10665 }
10666
10667 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
10668 managers.insert(
10669 "tm_test".to_string(),
10670 Box::new(MockTopicManager::admitting(vec![0])),
10671 );
10672 let seen = Rc::new(RefCell::new(Vec::new()));
10673 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
10674 lookup_services.insert(
10675 "ls_whole".to_string(),
10676 Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
10677 );
10678 lookup_services.insert(
10679 "ls_script".to_string(),
10680 Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
10681 );
10682 let engine = Engine::new(
10683 managers,
10684 lookup_services,
10685 Box::new(MemoryStorage::new()),
10686 None,
10687 EngineConfig::default(),
10688 );
10689
10690 let tagged = test_tagged_beef(vec!["tm_test"]);
10691 engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
10692 assert_eq!(seen.borrow().len(), 2, "one admit per service");
10693 let (txid, vout, _) = seen.borrow()[0].clone();
10694 assert_eq!(txid, TEST_TXID);
10695
10696 // the premise, RED-verified: the same bytes again are DEDUPED — /submit can never re-notify
10697 let (_, report) = engine
10698 .submit_with_report(&tagged, SubmitMode::CurrentTx)
10699 .await
10700 .unwrap();
10701 assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
10702 assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
10703
10704 // the verb: told again from the stored outputs, the WholeTx body naming the subject
10705 let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
10706 assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
10707 assert_eq!(r.vouts, vec![vout]);
10708 assert_eq!(seen.borrow().len(), 4);
10709 assert!(seen.borrow()[2..]
10710 .iter()
10711 .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
10712
10713 // a topic the engine never admitted this txid on: nothing to re-notify (re-submit instead)
10714 let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
10715 assert_eq!((r0.outputs, r0.notified), (0, 0));
10716 assert_eq!(seen.borrow().len(), 4);
10717
10718 // idempotent: a second re-notify tells everyone again, faults nothing
10719 let r2 = engine
10720 .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
10721 .await
10722 .unwrap();
10723 assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
10724 assert_eq!(seen.borrow().len(), 6);
10725 }
10726
10727 /// bsv-low PLAN-PRE-LOOP4 §H4: a PHANTOM applied row (the topic admitted
10728 /// NOTHING) dedups every re-submit of those bytes; forgetting it lets the
10729 /// same bytes be judged again — while a REAL admission's row is never
10730 /// forgotten and a missing row is nothing to forget.
10731 #[tokio::test]
10732 async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
10733 // phantom: a TM that admits nothing still records the topic as applied
10734 let engine = make_engine(vec![]);
10735 let tagged = test_tagged_beef(vec!["tm_test"]);
10736 let (steak, r1) = engine
10737 .submit_with_report(&tagged, SubmitMode::CurrentTx)
10738 .await
10739 .unwrap();
10740 assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
10741 assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
10742 let (_, r2) = engine
10743 .submit_with_report(&tagged, SubmitMode::CurrentTx)
10744 .await
10745 .unwrap();
10746 assert_eq!(
10747 r2.deduped_topics,
10748 vec!["tm_test".to_string()],
10749 "the phantom row dedups the re-submit"
10750 );
10751 assert!(engine
10752 .forget_phantom_applied(TEST_TXID, "tm_test")
10753 .await
10754 .unwrap());
10755 let (_, r3) = engine
10756 .submit_with_report(&tagged, SubmitMode::CurrentTx)
10757 .await
10758 .unwrap();
10759 assert!(
10760 r3.deduped_topics.is_empty(),
10761 "judged again after the forget"
10762 );
10763 assert!(
10764 !engine
10765 .forget_phantom_applied(TEST_TXID, "tm_other")
10766 .await
10767 .unwrap(),
10768 "no row: nothing to forget"
10769 );
10770
10771 // real: a TM that admits output 0 — its row is a real admission, never forgotten
10772 let real = make_engine(vec![0]);
10773 real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
10774 assert!(!real
10775 .forget_phantom_applied(TEST_TXID, "tm_test")
10776 .await
10777 .unwrap());
10778 let (_, r4) = real
10779 .submit_with_report(&tagged, SubmitMode::CurrentTx)
10780 .await
10781 .unwrap();
10782 assert_eq!(
10783 r4.deduped_topics,
10784 vec!["tm_test".to_string()],
10785 "a real admission still dedups"
10786 );
10787 }
10788
10789 // ── NL-6e: the SPV walk is linear in a BUMP's leaves (NL-8 W1) ─────
10790
10791 /// Named and generous (a debug build on a loaded machine): the walk at
10792 /// bsv-rs 0.4.0 took 30 to 36 s at 4,096 leaves in release and gave no
10793 /// answer in 120 s at 8,192 (NL-8 W1).
10794 const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
10795
10796 /// The wide BUMP's height (NL-8's `wide k` row).
10797 const WIDE_HEIGHT: u32 = 800_001;
10798
10799 /// `2^k` txids, display hex: the double SHA-256 of `i` (NL-8's probe).
10800 fn wide_leaves(k: u32) -> Vec<String> {
10801 (0..1u32 << k)
10802 .map(|i| {
10803 let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
10804 id.reverse();
10805 hex::encode(id)
10806 })
10807 .collect()
10808 }
10809
10810 /// One BUMP at [`WIDE_HEIGHT`] whose level 0 carries `txids` as hashed
10811 /// leaves and whose `k - 1` upper levels are empty: every node above
10812 /// level 0 is computed from it. Its root, by one walk.
10813 fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
10814 let k = txids.len().trailing_zeros() as usize;
10815 let mut levels = vec![Vec::new(); k];
10816 levels[0] = txids
10817 .iter()
10818 .enumerate()
10819 .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
10820 .collect();
10821 let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
10822 let root = bump.compute_root(None).unwrap();
10823 (bump, root)
10824 }
10825
10826 /// NL-8 W1's bytes: a V1 BEEF of the one wide BUMP and no transaction
10827 /// (`wide_14` at `2^k` leaves, byte for byte the runner's probe).
10828 fn w1_bytes(k: u32) -> (Vec<u8>, String) {
10829 let (bump, root) = wide_bump(&wide_leaves(k));
10830 let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
10831 beef.extend_from_slice(&bump.to_binary());
10832 beef.push(0x00);
10833 (beef, root)
10834 }
10835
10836 /// The entry, [`verify_spv_like_the_reference`], on its own thread under
10837 /// a tracker that carries `root` at [`WIDE_HEIGHT`]: its answer and its
10838 /// time, or a panic naming the bound.
10839 fn walk_within_bound(
10840 bytes: Vec<u8>,
10841 root: String,
10842 verify_scripts: bool,
10843 subject: String,
10844 what: &str,
10845 ) -> (Result<(), String>, std::time::Duration) {
10846 let (tx, rx) = std::sync::mpsc::channel();
10847 std::thread::spawn(move || {
10848 let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
10849 tracker.add_root(WIDE_HEIGHT, root);
10850 let runtime = tokio::runtime::Builder::new_current_thread()
10851 .build()
10852 .unwrap();
10853 let started = std::time::Instant::now();
10854 let verdict = runtime.block_on(verify_spv_like_the_reference(
10855 Some(&tracker),
10856 verify_scripts,
10857 &bytes,
10858 &subject,
10859 DoorBudget::DEFAULT,
10860 ));
10861 let verdict = match verdict {
10862 Ok(None) => Ok(()),
10863 Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
10864 Err(e) => Err(e.to_string()),
10865 };
10866 let _ = tx.send((verdict, started.elapsed()));
10867 });
10868 rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
10869 panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
10870 })
10871 }
10872
10873 /// The escape hatch's structural walk (`verify_scripts` false) on NL-8
10874 /// W1's bytes, 4,096 and 8,192 leaves: accepted, the one root asked of
10875 /// the tracker, well inside the bound.
10876 #[test]
10877 fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
10878 for k in [12u32, 13] {
10879 let (bytes, root) = w1_bytes(k);
10880 let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
10881 let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
10882 println!(
10883 "NL-6e W1 {what}: {verdict:?} in {:.3} s",
10884 took.as_secs_f64()
10885 );
10886 assert_eq!(verdict, Ok(()), "{what}");
10887 }
10888 }
10889
10890 /// The default walk (scripts run, `verify_scripts` true) over `2^k`
10891 /// proven parents in the one wide BUMP, each spent by a binary tree of
10892 /// two-input transactions whose root is the subject: every parent's
10893 /// root is the BUMP's, asked of the tracker, the inputs `2^(k+1) - 2`.
10894 fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
10895 use bsv_rs::script::{LockingScript, UnlockingScript};
10896 use bsv_rs::transaction::{TransactionInput, TransactionOutput};
10897 let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
10898 let spending = |txid: &str| {
10899 let mut input = TransactionInput::new(txid.to_string(), 0);
10900 input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
10901 input
10902 };
10903 let parents: Vec<Transaction> = (0..1u32 << k)
10904 .map(|i| {
10905 let mut tx = Transaction::new();
10906 // A coinbase's null outpoint, a distinct index each.
10907 let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
10908 input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
10909 tx.inputs.push(input);
10910 tx.outputs.push(TransactionOutput::new(1, op_true()));
10911 tx
10912 })
10913 .collect();
10914 let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
10915 let (bump, root) = wide_bump(&ids);
10916 let mut beef = Beef::new();
10917 let at = beef.merge_bump(bump);
10918 for parent in &parents {
10919 beef.merge_raw_tx(parent.to_binary(), Some(at));
10920 }
10921 let mut layer = ids;
10922 while layer.len() > 1 {
10923 layer = layer
10924 .chunks(2)
10925 .map(|pair| {
10926 let mut tx = Transaction::new();
10927 tx.inputs.push(spending(&pair[0]));
10928 tx.inputs.push(spending(&pair[1]));
10929 tx.outputs.push(TransactionOutput::new(0, op_true()));
10930 beef.merge_raw_tx(tx.to_binary(), None);
10931 tx.id()
10932 })
10933 .collect();
10934 }
10935 (beef.to_binary(), root, layer.remove(0))
10936 }
10937
10938 #[test]
10939 fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
10940 for k in [12u32, 13] {
10941 let (bytes, root, subject) = proven_fan_in(k);
10942 let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
10943 let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
10944 println!(
10945 "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
10946 took.as_secs_f64()
10947 );
10948 assert_eq!(verdict, Ok(()), "{what}");
10949 }
10950 }
10951}