Skip to main content

bsv_overlay_engine/
gasp.rs

1//! GASP — Graph Aware Sync Protocol.
2//!
3//! Defines the `GASPStorage` and `GASPRemote` traits, plus the `GASP` orchestrator
4//! that synchronizes overlay UTXOs between peers.
5//!
6//! Ported from:
7//! - `~/bsv/gasp-core/src/GASP.ts` (1,557 lines)
8//! - `~/bsv/overlay-services/src/GASP/OverlayGASPStorage.ts` (388 lines)
9//! - `~/bsv/overlay-services/src/GASP/OverlayGASPRemote.ts` (108 lines)
10
11use async_trait::async_trait;
12use tracing::{debug, error, info, warn};
13
14use crate::types::{
15    GASPInitialReply, GASPInitialRequest, GASPInitialResponse, GASPNode, GASPNodeResponse,
16    GASPOutput,
17};
18
19/// Current GASP protocol version.
20pub const GASP_VERSION: u32 = 1;
21
22/// Default sync limit per page.
23pub const DEFAULT_GASP_SYNC_LIMIT: u64 = 10000;
24
25// ============================================================================
26// Per-peer sync budgets + dead-peer quarantine (bsv-low#302)
27// ============================================================================
28
29/// PURE (bsv-low#257/#302): race `fut` against `deadline`; `None` = the
30/// deadline won and `fut` was DROPPED (its in-flight work cancelled).
31/// Injectable deadline so the control flow is natively unit-tested — the
32/// house idiom generalized out of `overlay-cloudflare`'s scheduled handler
33/// (which passes its platform `sleep_ms`) so the ENGINE's per-peer GASP
34/// budget can reuse it.
35pub async fn race_or_deadline<F, D, T>(fut: F, deadline: D) -> Option<T>
36where
37    F: std::future::Future<Output = T>,
38    D: std::future::Future<Output = ()>,
39{
40    let mut fut = std::pin::pin!(fut);
41    let mut deadline = std::pin::pin!(deadline);
42    std::future::poll_fn(move |cx| {
43        if let std::task::Poll::Ready(v) = fut.as_mut().poll(cx) {
44            return std::task::Poll::Ready(Some(v));
45        }
46        if deadline.as_mut().poll(cx).is_ready() {
47            return std::task::Poll::Ready(None);
48        }
49        std::task::Poll::Pending
50    })
51    .await
52}
53
54/// `deadline`, LATCHED: once it fell due it answers `Ready` on every poll and
55/// its inner future is never polled again (bsv-low #555, found by the
56/// delta-2 fold). The worker's sleeps are `async fn` futures, which PANIC
57/// when polled after they completed, and a per-graph deadline is polled
58/// again after it fell due (the shared resume deadline, kept for the next
59/// record; the half deadline of D2-L1).
60pub fn latched(deadline: crate::engine::SleepFuture) -> crate::engine::SleepFuture {
61    let mut inner = Some(deadline);
62    Box::pin(std::future::poll_fn(move |cx| {
63        let Some(deadline) = inner.as_mut() else {
64            return std::task::Poll::Ready(());
65        };
66        if deadline.as_mut().poll(cx).is_ready() {
67            inner = None;
68            return std::task::Poll::Ready(());
69        }
70        std::task::Poll::Pending
71    }))
72}
73
74/// What makes a deadline COOPERATIVE around a write (bsv-low #552, the lens
75/// fold's HIGH-1). `Engine::submit` is several storage writes (mark spent,
76/// delete the stale coin, insert the outputs, notify, record applied), each
77/// an await on D1, and a future dropped between two of them leaves a head
78/// chain with NO head: the old one deleted, the new one never inserted, and
79/// no applied row to say so.
80///
81/// The writer opens a [`WriteSection`] around ONE transaction's submit and
82/// asks [`SubmitGate::stop_if_due`] between two of them.
83/// [`race_or_deadline_guarded`] does not drop its future while a section is
84/// open: it notes the deadline as due and keeps polling until the section
85/// closes, so the wait is bounded by the writes of one transaction. What the
86/// deadline leaves is then a prefix of whole transactions, never part of one.
87/// The race does not bound a write that never answers: the writer does
88/// (bsv-low #559, `Engine::set_finalize_submit_budget`: the engine bounds
89/// each storage call and hook inside the section, and a call that never
90/// answers is that call's fault; the submit itself is never dropped).
91#[derive(Debug, Default)]
92pub struct SubmitGate {
93    writing: std::cell::Cell<u32>,
94    due: std::cell::Cell<bool>,
95}
96
97impl SubmitGate {
98    /// Open a section no guarded race may drop. Closed when the returned
99    /// value is dropped.
100    pub fn write_section(&self) -> WriteSection<'_> {
101        self.writing.set(self.writing.get() + 1);
102        WriteSection(self)
103    }
104
105    /// Whether a guarded race's deadline fell due while a section was open:
106    /// the race is waiting for the writer to stop.
107    pub fn deadline_is_due(&self) -> bool {
108        self.due.get()
109    }
110
111    /// The writer's question at a transaction boundary, with NO section open.
112    /// If a deadline is due this never returns: it hands the future back to
113    /// the race, which drops it on this very poll (only a guarded race sets
114    /// the flag, and it clears it when it ends).
115    pub async fn stop_if_due(&self) {
116        if self.due.get() {
117            std::future::pending::<()>().await;
118        }
119    }
120}
121
122/// An open write section of a [`SubmitGate`].
123#[must_use = "the section closes when this is dropped"]
124pub struct WriteSection<'g>(&'g SubmitGate);
125
126impl Drop for WriteSection<'_> {
127    fn drop(&mut self) {
128        self.0.writing.set(self.0.writing.get().saturating_sub(1));
129    }
130}
131
132/// [`race_or_deadline`] that never drops `fut` inside a [`WriteSection`] of
133/// `gate`. A deadline that falls due while a section is open is remembered
134/// (and shown to the writer, [`SubmitGate::deadline_is_due`]); `fut` is
135/// polled on, by its own wakers, and dropped at its first pending point
136/// outside a section. `None` then means what it meant: the deadline won.
137pub async fn race_or_deadline_guarded<F, D, T>(fut: F, deadline: D, gate: &SubmitGate) -> Option<T>
138where
139    F: std::future::Future<Output = T>,
140    D: std::future::Future<Output = ()>,
141{
142    // The flag never outlives the race that set it, however the race ends
143    // (an outer drop included): a stale one would park the next writer.
144    struct ClearDue<'g>(&'g SubmitGate);
145    impl Drop for ClearDue<'_> {
146        fn drop(&mut self) {
147            self.0.due.set(false);
148        }
149    }
150    let _clear = ClearDue(gate);
151    let mut fut = std::pin::pin!(fut);
152    let mut deadline = std::pin::pin!(deadline);
153    let mut due = false;
154    std::future::poll_fn(move |cx| {
155        if let std::task::Poll::Ready(v) = fut.as_mut().poll(cx) {
156            return std::task::Poll::Ready(Some(v));
157        }
158        if !due && deadline.as_mut().poll(cx).is_ready() {
159            due = true;
160        }
161        if due {
162            if gate.writing.get() == 0 {
163                return std::task::Poll::Ready(None);
164            }
165            // `fut` is parked inside a write: its own waker brings us back.
166            gate.due.set(true);
167        }
168        std::task::Poll::Pending
169    })
170    .await
171}
172
173/// Consecutive all-failure syncs after which a peer is QUARANTINED
174/// (bsv-low#302). At the production `*/15` cron cadence 8 consecutive
175/// failures ≈ 2 hours of unbroken unreachability — comfortably past any
176/// transient blip (deploy, restart, brief network fault, one bad tick),
177/// while still cutting a genuinely dead peer's budget burn off the same
178/// day it dies.
179pub const PEER_QUARANTINE_THRESHOLD: u64 = 8;
180
181/// Re-probe interval for a quarantined peer, seconds (6 h ≈ every 24 cron
182/// ticks). Quarantine is NEVER a deletion: once `secs_since_last_attempt`
183/// reaches this age the peer gets exactly ONE fresh sync attempt — success
184/// resets its failure count to 0 (full re-admission), failure re-arms the
185/// quarantine for another window. A peer that died transiently therefore
186/// re-admits itself automatically on the first re-probe after it recovers.
187pub const PEER_QUARANTINE_REPROBE_SECS: u64 = 6 * 3600;
188
189/// Consecutive YIELDLESS syncs of a (peer, topic) allowed before each further
190/// one is a FAILED attempt for the quarantine (bsv-low #555, the delta fold's
191/// D-M2). A sync is yieldless when the peer served it work it did not hold
192/// ([`GASPSync::graphs_attempted`] > 0) and it finalized no graph and moved no
193/// cursor; it then counts a success only because a walk PROGRESSED (a node
194/// appended), and a node is the peer's word: a hostile peer that lists one
195/// fresh fabricated UTXO a tick, serves its root and hangs on its input kept
196/// its slice for ever. Past this many in a row such a sync is a failure, so
197/// that peer is quarantined after this many plus
198/// [`PEER_QUARANTINE_THRESHOLD`] syncs (20, 5 h at `*/15`) and gets one probe
199/// per [`PEER_QUARANTINE_REPROBE_SECS`] after that. A sync that finalized a
200/// graph or moved the cursor resets the count; a quiet one leaves it.
201/// A quarantined peer is re-admitted only by such a yield (the delta-4
202/// fold's D4-L2): its sync that did not fail and yielded nothing, an empty
203/// listing at its probe included, is not recorded, so its failures stay and
204/// it is attended on each tick until it yields or fails again.
205///
206/// The bound is in SYNCS AND in TIME (the delta-2 fold's D2-M1): a yieldless
207/// sync is a failure only when the streak holds more than this many syncs
208/// AND began at least [`PEER_YIELDLESS_SECS_ALLOWED`] ago
209/// ([`yieldless_sync_failed`]). Counted in syncs alone, 12 was three hours at
210/// `*/15` and twelve minutes at LOW's one tick a minute, under the tail of a
211/// block interval: an honest peer whose only new work was #582's unproven
212/// head was quarantined while it waited for its block.
213///
214/// Why 12 and 3 h: an honest deep graph converges well inside both. The
215/// measured one (#582) is an UNPROVEN head over unproven ancestry: its root
216/// is re-asked at each resume and the pass after its block lands restarts the
217/// walk from a proven root and completes it (`root_proven`), one or two passes
218/// after a block that comes in ~10 min on average (past 3 h about once in
219/// 10^8 blocks by the exponential model, at any cadence). Walked to its end
220/// instead, 12 passes are about 150 nodes on the Cloudflare worker (15 s a
221/// pass at 1.2 s a request) and 600 on LOW's node (60 s). Applied only with a
222/// per-graph budget (`Engine::set_graph_budget`): without one no walk is
223/// deferred and the quarantine is the one of #302.
224pub const PEER_YIELDLESS_SYNCS_ALLOWED: u64 = 12;
225
226/// The AGE a yieldless streak must reach, seconds since its first yieldless
227/// sync, before a yieldless sync is a failure (3 h; the delta-2 fold's
228/// D2-M1, see [`PEER_YIELDLESS_SYNCS_ALLOWED`]). The storage keeps the
229/// streak's start ([`crate::storage::PeerYieldStreak`]) on its own clock.
230pub const PEER_YIELDLESS_SECS_ALLOWED: u64 = 3 * 3600;
231
232/// The DECAY of a yieldless streak (the delta-2 fold's D2-M1): a yieldless
233/// sync more than this long (6 h, [`PEER_QUARANTINE_REPROBE_SECS`]) after the
234/// streak's LAST yieldless sync starts a new streak (count 1, its start now)
235/// instead of adding to the old one. Quiet syncs in between leave the streak
236/// as it is (a quiet sync is not a yield), so an honest peer whose rare
237/// graphs that never land are hours apart is not counted across days. The
238/// cost, stated: a peer yieldless for 3 h, quiet for 6 h, and so on, keeps
239/// its slice during the yieldless hours (a third of its ticks at most), as
240/// #302's own accepted residual of one success in every eight.
241///
242/// Only for a peer that is NOT failed (`consecutive_failures` 0; the delta-3
243/// fold's D3-M1). This window equals the reprobe's, so the probe of a peer
244/// quarantined by this bound always came more than 6 h after its last
245/// yieldless sync: it started a fresh streak (count 1, not failed), the sync
246/// "progressed", the failures went to 0 and the quarantine lifted at every
247/// probe (half the ticks at `*/15`). The probe of a failed peer continues
248/// its streak and fails, so it gets one probe per reprobe window.
249pub const PEER_YIELDLESS_DECAY_SECS: u64 = PEER_QUARANTINE_REPROBE_SECS;
250
251/// PURE rule of the yieldless bound (bsv-low #555, the delta-2 fold's
252/// D2-M1): a yieldless sync is a FAILED attempt IFF its streak holds more
253/// than [`PEER_YIELDLESS_SYNCS_ALLOWED`] syncs AND is at least
254/// [`PEER_YIELDLESS_SECS_ALLOWED`] old. A streak of unknown age (a backend
255/// with no clock) is never past (fail-safe, as #302's unknown attempt age).
256pub fn yieldless_sync_failed(streak: &crate::storage::PeerYieldStreak) -> bool {
257    streak.yieldless_syncs > PEER_YIELDLESS_SYNCS_ALLOWED
258        && streak
259            .secs_since_first
260            .is_some_and(|secs| secs >= PEER_YIELDLESS_SECS_ALLOWED)
261}
262
263/// The NORMALIZED ORIGIN of a peer URL (bsv-low #555, the delta-2 fold's
264/// D2-M2): its host, lowercased, with no scheme, user, port, path, query or
265/// fragment, and no trailing dot. The key of a peer's quarantine and
266/// yieldless streak (the engine passes it to the three peer-health methods
267/// of [`crate::storage::Storage`]) and of its share of a storage's deferred
268/// graph ceiling (the worker's `origin` column): keyed on the URL string, one
269/// server was eight "hosts" for eight adverts (`?1`, `:8443`, a trailing
270/// slash, `#x`), each with its own share and a fresh streak. A subdomain is
271/// another host still (no public suffix list here): stated. The cursor and
272/// the deferred records stay keyed by the URL the peer is synced at.
273pub fn peer_origin(url: &str) -> String {
274    peer_authority(url).1
275}
276
277/// `(scheme, host, port)` of a peer URL: the scheme lowercased (`https`
278/// when absent), the host as [`peer_origin`] gives it, the port as written
279/// (`None` when absent, empty, or the scheme's default: `443` for `https`,
280/// `80` for `http`).
281fn peer_authority(url: &str) -> (String, String, Option<String>) {
282    let url = url.trim();
283    let (scheme, rest) = url.split_once("://").unwrap_or(("https", url));
284    let scheme = scheme.to_ascii_lowercase();
285    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
286    let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
287    let (host, port) = match host_port.strip_prefix('[') {
288        // `[::1]:8443`: the bracketed address, brackets kept.
289        Some(v6) => match v6.split_once(']') {
290            Some((addr, after)) => (format!("[{addr}]"), after.strip_prefix(':')),
291            None => (host_port.to_string(), None),
292        },
293        None => match host_port.split_once(':') {
294            Some((host, port)) => (host.to_string(), Some(port)),
295            None => (host_port.to_string(), None),
296        },
297    };
298    let default_port = match scheme.as_str() {
299        "https" => "443",
300        "http" => "80",
301        _ => "",
302    };
303    let port = port.filter(|p| !p.is_empty() && *p != default_port);
304    (
305        scheme,
306        host.trim_end_matches('.').to_ascii_lowercase(),
307        port.map(str::to_string),
308    )
309}
310
311/// The peers of a SHIP-discovered topic, ONE per [`peer_origin`] (bsv-low
312/// #555, the delta-2 fold's D2-M2; restored by the delta-4 fold, D4-M1): each
313/// advertised domain is canonicalized to `scheme://host[:port]` (the scheme
314/// and host lowercased; path, query, fragment, user, a trailing dot and the
315/// scheme's default port dropped), and of the spellings of one origin the
316/// first by `https`, then no explicit port, then the string is kept. So
317/// eight adverts of one server (`/?1`, `/?2`, `/#x`, `:8443`, ...) are one
318/// peer synced once a tick, and a stranger's spelling of an honest host (a
319/// query, a dead port) cannot stand beside that host's own advert under its
320/// quarantine key. The delta-3 fold kept one peer per `scheme://host[:port]`
321/// while the failures stayed on one row per origin (D4-M1): eight adverts of
322/// dead ports of an honest host's name quarantined the honest peer, and
323/// seven were never quarantined, a slice each on every tick. The LIMIT,
324/// stated (the delta-3 lens's D3-L1): two overlays of one host on two ports,
325/// on one SHIP topic, are one peer (the default port's), so a stranger's
326/// default-port spelling displaces an honest overlay that serves only on
327/// another port. Sorted, for a stable order.
328pub fn ship_peers_by_origin<I: IntoIterator<Item = String>>(domains: I) -> Vec<String> {
329    type Rank = (bool, bool, String);
330    let mut by_origin: std::collections::BTreeMap<String, (Rank, String)> =
331        std::collections::BTreeMap::new();
332    for domain in domains {
333        let (scheme, origin, port) = peer_authority(&domain);
334        if origin.is_empty() {
335            continue;
336        }
337        let canonical = match &port {
338            Some(port) => format!("{scheme}://{origin}:{port}"),
339            None => format!("{scheme}://{origin}"),
340        };
341        let candidate = (scheme != "https", port.is_some(), canonical.clone());
342        match by_origin.get(&origin) {
343            Some((kept, _)) if *kept <= candidate => {}
344            _ => {
345                by_origin.insert(origin, (candidate, canonical));
346            }
347        }
348    }
349    let mut peers: Vec<String> = by_origin.into_values().map(|(_, url)| url).collect();
350    peers.sort();
351    peers
352}
353
354/// PURE quarantine rule (bsv-low#302): a peer is skipped IFF it has hit
355/// [`PEER_QUARANTINE_THRESHOLD`] consecutive failures AND its last attempt
356/// is younger than [`PEER_QUARANTINE_REPROBE_SECS`]. A peer with no
357/// recorded attempt (`secs_since_last_attempt == None` — pristine, or a
358/// backend without peer-health tracking) is NEVER quarantined, and an old
359/// enough last attempt always re-opens one probe — fail-safe in the
360/// "attempt more, never strand a peer forever" direction.
361///
362/// ACCEPTED RESIDUAL (bsv-low#304 gate M-3, documented — not built): a
363/// peer that ALTERNATES one success into every window of ≤7 failures
364/// resets its streak each time and is never quarantined, burning up to one
365/// per-peer budget slice (30 s) per (host, topic) per tick forever. Why
366/// accepted: (a) the burn is bounded by the per-peer budget times the
367/// configured peer set, all inside the 240 s outer step belt — the #257
368/// unbounded-hang class cannot recur; (b) sustaining it requires the peer
369/// to actually COMPLETE a real sync every few ticks, i.e. behave as a
370/// (slow) live peer — indistinguishable in principle from a genuinely
371/// flaky honest peer, which the rule must never strand; (c) LOW's money
372/// topics do not GASP-sync at all (no `sync_configuration` peers in prod),
373/// so the exposure is cron-budget noise, not a money surface. A
374/// streak-decay or success-ratio rule could narrow it later if a real
375/// abuser appears.
376pub fn peer_sync_quarantined(health: &crate::storage::PeerSyncHealth) -> bool {
377    health.consecutive_failures >= PEER_QUARANTINE_THRESHOLD
378        && health
379            .secs_since_last_attempt
380            .is_some_and(|secs| secs < PEER_QUARANTINE_REPROBE_SECS)
381}
382
383// ============================================================================
384// Deferred graphs (bsv-low #555)
385// ============================================================================
386
387/// Default per-GRAPH call budget (bsv-low #555): requests to the peer, or
388/// chain fetches, that ONE graph's walk may make in one pass before it is
389/// deferred. With [`DEFAULT_GRAPH_BUDGET_MS`]: at the 1.2 s per request
390/// measured on beta (2026-10-09, #582) 100 calls take about 120 s, so on a
391/// slow peer the time binds first (about 50 nodes a pass) and on a fast one
392/// the calls do, which also bounds how much a record grows per pass.
393pub const DEFAULT_GRAPH_BUDGET_CALLS: u32 = 100;
394
395/// Default per-GRAPH time budget, ms (bsv-low #555): half of the 120 s
396/// topic slice of LOW's node (one peer), so a deep graph takes at most half
397/// of its topic's slice and the UTXOs after it keep the other half. A caller
398/// keeps it BELOW its per-peer budget (`Engine::set_peer_sync_budget`); a
399/// graph the per-peer deadline cuts first is deferred all the same.
400pub const DEFAULT_GRAPH_BUDGET_MS: u64 = 60_000;
401
402/// Default per-GRAPH bytes budget (bsv-low #586): the bytes of the nodes ONE
403/// graph's walk may be SERVED in one pass before it is deferred, counted as
404/// the peer serves them and as a record keeps them (the hex of each node's
405/// raw transaction and of its proof, every answer counted, a repeat too).
406/// A BUDGET PER PASS, not a limit: the walk makes at least one request a
407/// pass whatever its size, and what the pass leaves is resumed.
408///
409/// 917,504 bytes: a budget per pass and nothing else (bsv-low #585). It was
410/// derived from a 1 MiB cap on a record (seven eighths of it, the lens fold's
411/// E586-L1) so that the pass it cut left a record that fitted; a record has
412/// no cap since #585 (the storage keeps one of any size, the worker in
413/// chunks), so the figure is kept for what it measures, 18 heads of 26 KB a
414/// fresh pass, and no longer sits "under" anything. A consumer may set any
415/// other ([`GraphBudget::max_bytes_fetched`]).
416pub const DEFAULT_GRAPH_BUDGET_BYTES: u64 = 917_504;
417
418/// Default per-GRAPH node budget (bsv-low #586): the nodes ONE graph's walk
419/// may APPEND in one pass before it is deferred (five times the 12 of
420/// Zanaadu's largest legitimate graph). As [`DEFAULT_GRAPH_BUDGET_BYTES`], a
421/// budget per pass that resumes; it also bounds the JSON a pass adds to a
422/// record around its nodes' hex, which the bytes limb does not count.
423pub const DEFAULT_GRAPH_BUDGET_NODES: u32 = 64;
424
425/// A deferred graph whose record has been deferred this many passes is
426/// dropped at its next resume (reason `max_passes`) and its UTXO fails as a
427/// failed ingest does: the gap guard asks for it again and the next pass
428/// walks it from its root. One hour at a one-minute cadence, fifteen at
429/// `*/15`.
430pub const DEFERRED_GRAPH_MAX_PASSES: u32 = 60;
431
432/// The most records one (peer, topic) may hold. A new deferral past it is
433/// not saved (reason `too_many`) and its walk goes on under the per-peer
434/// budget alone, as before #555 (the lens fold's L4): a peer that serves many
435/// deep graphs costs at most 16 records. A storage may refuse a record at a
436/// ceiling of its own ([`DeferredGraphSave::AtCeiling`]), counted `too_many`
437/// too (the worker's global ceiling, the lens fold's M3). A record has NO
438/// byte bound (bsv-low #585: it had 1 MiB, `too_big`): a graph of any size
439/// is deferred and resumed whole, and the storage's ceiling is a BUDGET of
440/// room, under which a RESUMED walk keeps the record it holds.
441pub const DEFERRED_GRAPHS_PER_PEER_TOPIC: usize = 16;
442
443/// A RESUMED walk that faults having appended no node keeps its record (an
444/// honest peer's transient 5xx), but not for free (bsv-low #555, the
445/// delta-2 fold's D2-M2): at this many such passes in a row the record is
446/// dropped (reason `idle_faults`) and the UTXO fails, as a fresh walk's
447/// fault does. Before the fold a stranger's record was rewritten for one
448/// quick 503 a tick and held its place for its 60 passes (and the sync is
449/// yieldless, so it counts against the yieldless bound too).
450pub const DEFERRED_GRAPH_MAX_IDLE_FAULTS: u32 = 3;
451
452/// The per-graph budget of a [`GASPSync`] (bsv-low #555,
453/// `Engine::set_graph_budget`). Setting one turns deferral ON for that sync.
454pub struct GraphBudget<'a> {
455    /// Calls one graph may make in one pass ([`DEFAULT_GRAPH_BUDGET_CALLS`]).
456    pub max_calls: u32,
457    /// Bytes one graph may be served in one pass
458    /// ([`DEFAULT_GRAPH_BUDGET_BYTES`], bsv-low #586). Reached, the graph is
459    /// DEFERRED as at `max_calls` (reason `bytes`): never dropped, never
460    /// refused.
461    pub max_bytes_fetched: u64,
462    /// Nodes one graph may append in one pass
463    /// ([`DEFAULT_GRAPH_BUDGET_NODES`], bsv-low #586). Reached, the graph is
464    /// deferred (reason `nodes`).
465    pub max_nodes: u32,
466    /// A fresh deadline for ONE graph's pass ([`DEFAULT_GRAPH_BUDGET_MS`]).
467    pub deadline: Box<dyn Fn() -> crate::engine::SleepFuture + 'a>,
468    /// A fresh deadline for HALF of it (the delta-2 fold's D2-L1): a FRESH
469    /// walk that faults once this fell due, or after half its calls, has
470    /// paid for a record and keeps one. `None`: the calls alone decide.
471    pub half_deadline: Option<Box<dyn Fn() -> crate::engine::SleepFuture + 'a>>,
472}
473
474impl GraphBudget<'_> {
475    /// What one pass of one graph may spend.
476    fn pass(&self) -> PassCap {
477        PassCap {
478            calls: self.max_calls,
479            bytes: self.max_bytes_fetched,
480            nodes: self.max_nodes,
481        }
482    }
483}
484
485/// What one pass of a graph's walk may still spend of the per-graph budget's
486/// three counted limbs (its time is a deadline): calls made, bytes served,
487/// nodes appended.
488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
489struct PassCap {
490    calls: u32,
491    bytes: u64,
492    nodes: u32,
493}
494
495impl PassCap {
496    /// No bound: a sync with no per-graph budget, or a walk that goes on
497    /// under the per-peer budget alone (L4).
498    const NONE: PassCap = PassCap {
499        calls: u32::MAX,
500        bytes: u64::MAX,
501        nodes: u32::MAX,
502    };
503
504    /// Whether a limb is spent to nothing.
505    fn spent(self) -> bool {
506        self.calls == 0 || self.bytes == 0 || self.nodes == 0
507    }
508}
509
510/// The key of one held record (bsv-low #555): what a sync loads up front.
511/// The record itself is read only when the peer serves its UTXO
512/// ([`GASPStorage::get_deferred_graph`]), so at most one record is in memory
513/// at a time (the lens fold's L3: the up-front load held up to 16 of them).
514#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
515#[serde(rename_all = "camelCase")]
516pub struct DeferredGraphKey {
517    /// The root UTXO, `txid.outputIndex`.
518    pub outpoint: String,
519    /// The root UTXO's score at the peer.
520    pub score: u64,
521}
522
523/// What a storage did with a record it was asked to save (bsv-low #555).
524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
525pub enum DeferredGraphSave {
526    /// Saved (replaced).
527    Saved,
528    /// Refused at a ceiling of the storage's own (the worker's global count
529    /// and byte bounds, the lens fold's M3): counted `too_many`, and the walk
530    /// goes on under the per-peer budget alone, as before #555. A BUDGET
531    /// signal, never the loss of a walk (bsv-low #585): the storage must
532    /// leave the record it HELD under that key as it was, and the engine
533    /// keeps it, so a resumed graph refused room resumes from that record at
534    /// the next pass and never walks from its root again.
535    AtCeiling,
536}
537
538/// One node a deferred walk has fetched and appended, in append order.
539#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
540#[serde(rename_all = "camelCase")]
541pub struct WalkedNode {
542    /// The node as it was appended (the root's proof hydrated, if it was).
543    pub node: GASPNode,
544    /// `txid.outputIndex` of the node that spends it; `None` for the root.
545    pub spent_by: Option<String>,
546}
547
548/// One input a deferred walk has still to ask for.
549#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
550#[serde(rename_all = "camelCase")]
551pub struct PendingInput {
552    /// `txid.outputIndex` of the input (for the root, the UTXO itself).
553    pub outpoint: String,
554    /// The graph id the request carries (the root's outpoint).
555    pub graph_id: String,
556    /// Whether the input's metadata is asked for.
557    pub metadata: bool,
558    /// `txid.outputIndex` of the node that needs it; `None` for the root.
559    pub spent_by: Option<String>,
560    /// Whether that node is PROVEN: a named input the peer does not hold is
561    /// then pruned (the D8 rule), else it fails the graph.
562    pub parent_proven: bool,
563}
564
565/// The persisted partial walk of ONE graph (bsv-low #555): one record per
566/// (peer, topic, root outpoint), REPLACED on every deferral, never appended.
567#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
568#[serde(rename_all = "camelCase")]
569pub struct DeferredGraph {
570    /// The peer the graph is walked from.
571    pub peer: String,
572    /// The topic.
573    pub topic: String,
574    /// The root UTXO, `txid.outputIndex`.
575    pub outpoint: String,
576    /// The root UTXO's score at the peer.
577    pub score: u64,
578    /// The nodes fetched so far, with their proofs, in append order.
579    pub nodes: Vec<WalkedNode>,
580    /// The inputs still to ask for, a stack (the last is asked next).
581    pub pending: Vec<PendingInput>,
582    /// Calls spent on this graph over every pass.
583    pub calls: u64,
584    /// Passes that deferred it (its age, in passes).
585    pub passes: u32,
586    /// Why it was last deferred: `calls`, `time`, `bytes`, `nodes`, `fault`,
587    /// `anchor_unavailable`, `anchor_walk_held` (the E592 lens fold: the
588    /// anchor's walk past the budget, held until the root is served proven),
589    /// `not_landed` or `peer_deadline`.
590    pub reason: String,
591    /// Whether the peer is a CONFIGURED one (`SyncTarget::Peers`), not one
592    /// `ls_ship` discovered (bsv-low #555, the delta-2 fold's D2-M2): a
593    /// storage reserves part of its ceiling for these (the worker: records
594    /// of discovered peers hold at most half of it). Set by the storage
595    /// adapter on every save; `false` in a record saved before the fold.
596    #[serde(default)]
597    pub configured: bool,
598    /// Consecutive RESUMED passes that faulted having appended no node
599    /// (the delta-2 fold's D2-M2): the record is dropped (`idle_faults`)
600    /// when it reaches [`DEFERRED_GRAPH_MAX_IDLE_FAULTS`]; a pass that
601    /// appends a node resets it.
602    #[serde(default)]
603    pub idle_faults: u32,
604}
605
606impl DeferredGraph {
607    /// The record's size as stored (its JSON): what the deferral's log line
608    /// and a storage's byte budget read. Nothing refuses a record for it
609    /// (bsv-low #585).
610    pub fn byte_size(&self) -> usize {
611        serde_json::to_vec(self).map_or(usize::MAX, |b| b.len())
612    }
613}
614
615/// Why a deferred graph's record was deleted without converging.
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
617#[serde(rename_all = "snake_case")]
618pub enum DropReason {
619    /// Deferred [`DEFERRED_GRAPH_MAX_PASSES`] times; the UTXO fails.
620    MaxPasses,
621    /// Past [`DEFERRED_GRAPHS_PER_PEER_TOPIC`] or the storage's ceiling: a
622    /// walk the budget cut goes on under the per-peer budget alone (L4),
623    /// else the UTXO fails. A RESUMED walk's held record is KEPT (bsv-low
624    /// #585): the count is of the pass that found no room, not of a record.
625    TooMany,
626    /// The record could not be written; as [`Self::TooMany`] (D-L2).
627    StoreFault,
628    /// A sync that ran to its end was not served the UTXO (spent at the peer).
629    NotServed,
630    /// The node already holds the UTXO (it landed another way).
631    Held,
632    /// The peer answered it does not hold an input an UNPROVEN node needs;
633    /// the UTXO fails, as in the reference.
634    NotHeld,
635    /// The root came back PROVEN: the walk restarts from it, shorter.
636    RootProven,
637    /// The anchor check refused the graph (a final verdict, the cursor moves).
638    Refused,
639    /// A resumed record whose pass ended still holding no node (a record
640    /// saved before the lens fold's H1, which saves none such); the UTXO
641    /// fails.
642    NoProgress,
643    /// [`DEFERRED_GRAPH_MAX_IDLE_FAULTS`] resumed passes in a row faulted
644    /// having appended no node (the delta-2 fold's D2-M2); the UTXO fails.
645    IdleFaults,
646}
647
648impl DropReason {
649    /// Every reason, for a caller that serves a counter per reason.
650    pub const ALL: [DropReason; 10] = [
651        Self::MaxPasses,
652        Self::TooMany,
653        Self::StoreFault,
654        Self::NotServed,
655        Self::Held,
656        Self::NotHeld,
657        Self::RootProven,
658        Self::Refused,
659        Self::NoProgress,
660        Self::IdleFaults,
661    ];
662
663    /// The reason's name, as logged and counted.
664    pub fn as_str(self) -> &'static str {
665        match self {
666            Self::MaxPasses => "max_passes",
667            Self::TooMany => "too_many",
668            Self::StoreFault => "store_fault",
669            Self::NotServed => "not_served",
670            Self::Held => "held",
671            Self::NotHeld => "not_held",
672            Self::RootProven => "root_proven",
673            Self::Refused => "refused",
674            Self::NoProgress => "no_progress",
675            Self::IdleFaults => "idle_faults",
676        }
677    }
678}
679
680/// One record dropped in a sync.
681#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
682pub struct DroppedGraph {
683    /// The root outpoint.
684    pub outpoint: String,
685    /// Why.
686    pub reason: DropReason,
687}
688
689/// What a sync did with deferred graphs (bsv-low #555).
690#[derive(Debug, Clone, Default, PartialEq)]
691pub struct DeferralStats {
692    /// Graphs deferred (a new record, or a resumed one deferred again).
693    pub deferred: u64,
694    /// Graphs resumed from a record.
695    pub resumed: u64,
696    /// Resumed graphs that completed and landed (their record deleted).
697    pub converged: u64,
698    /// Records deleted without converging.
699    pub dropped: Vec<DroppedGraph>,
700    /// Walks of the sync that ended unfinished (deferred, or dropped at a
701    /// bound) having appended at least one node in their pass, or that
702    /// completed the graph (the lens fold's H1): the peer answered.
703    pub progressed: u64,
704    /// Walks of the sync that ended unfinished having appended NO node in
705    /// their pass (a hung or failing peer; the lens fold's H1). A sync whose
706    /// only outcome was these is a FAILED attempt for #302's quarantine.
707    pub stalled: u64,
708    /// Records NOT resumed this sync because the resumes of the pass had
709    /// spent their shared budget (the lens fold's M1); held, untouched, for
710    /// the next sync.
711    pub held_back: u64,
712}
713
714impl Walk {
715    /// The counted limb this pass has spent, if any: its name is the
716    /// deferral's reason.
717    fn over(&self) -> Option<&'static str> {
718        if self.calls_this_pass >= self.cap.calls {
719            Some("calls")
720        } else if self.bytes_this_pass >= self.cap.bytes {
721            Some("bytes")
722        } else if self.nodes_this_pass >= self.cap.nodes {
723            Some("nodes")
724        } else {
725            None
726        }
727    }
728}
729
730/// The walk of the graph in hand: its record (what a deferral saves) and
731/// the per-pass state. Lives in the [`GASPSync`], OUTSIDE the walk's
732/// future, so a deadline that drops the future leaves it whole.
733struct Walk {
734    record: DeferredGraph,
735    seen: std::collections::HashSet<String>,
736    calls_this_pass: u32,
737    /// Bytes served to this pass (bsv-low #586; see
738    /// [`DEFAULT_GRAPH_BUDGET_BYTES`] for what is counted).
739    bytes_this_pass: u64,
740    /// Nodes this pass appended (bsv-low #586).
741    nodes_this_pass: u32,
742    resumed: bool,
743    /// What this pass may spend: the per-graph budget, or what the resumes
744    /// of the pass have left of their shared one (M1); [`PassCap::NONE`]
745    /// once a walk that cannot be kept goes on under the per-peer budget
746    /// alone (L4).
747    cap: PassCap,
748    /// `record.nodes.len()` when the pass started (H1).
749    nodes_at_start: usize,
750    /// Whether this pass was already counted progressed or stalled.
751    counted: bool,
752    /// The walk could not be kept and GOES ON under the per-peer budget
753    /// alone (L4): its drop is counted once, when it went on, and it is
754    /// never saved again in this pass (the delta fold's D-L1: a per-peer
755    /// deadline that then cut it counted the drop twice and asked the
756    /// storage a second time).
757    gone_on: bool,
758    /// A FRESH walk's half-budget deadline, started with the walk (the
759    /// delta-2 fold's D2-L1); `None` for a resumed walk or with no
760    /// [`GraphBudget::half_deadline`].
761    half: Option<crate::engine::SleepFuture>,
762}
763
764/// How a graph's walk ended in this pass.
765enum WalkEnd {
766    /// Nothing is pending: complete the graph.
767    Done,
768    /// Deferred, with this reason.
769    Deferred(&'static str),
770}
771
772/// What one step of the walk got.
773enum StepOut {
774    Pruned,
775    Seen,
776    Appended {
777        node_id: String,
778        walked: Box<WalkedNode>,
779        children: Vec<PendingInput>,
780    },
781}
782
783/// How [`GASPSync::ingest_utxo`] ended for a UTXO.
784enum Ingested {
785    Completed,
786    Deferred,
787    /// A record not resumed: the pass's resumes spent their shared budget.
788    HeldBack,
789}
790
791/// What [`GASPSync::save_walk`] did with the walk in hand.
792enum Saved {
793    Yes,
794    /// Not kept, for this reason; the walk is handed back so the caller can
795    /// go on with it (L4) or drop it.
796    Unkept(DropReason, Box<Walk>),
797    /// The storage faulted (counted `store_fault`, a held record deleted);
798    /// the walk is handed back so the caller can go on with it (D-L2).
799    Fault(GASPError, Box<Walk>),
800    /// A RESUMED walk the storage had no room for (`AtCeiling`; counted
801    /// `too_many`): the record it holds is KEPT as it was (bsv-low #585), and
802    /// the walk is handed back so the caller can go on with it.
803    Ceiling(Box<Walk>),
804    /// The walk had already gone on (D-L1): nothing saved, nothing counted.
805    WentOn,
806}
807
808// ============================================================================
809// GASPStorage trait
810// ============================================================================
811
812/// Local storage interface for the GASP protocol.
813///
814/// Manages known UTXOs, temporary graph construction during sync,
815/// and graph validation/finalization.
816#[async_trait(?Send)]
817pub trait GASPStorage {
818    /// Returns UTXOs known to be unspent since the given score/timestamp.
819    /// Non-confirmed UTXOs should always be returned regardless of `since`.
820    async fn find_known_utxos(
821        &self,
822        since: u64,
823        limit: Option<u64>,
824    ) -> Result<Vec<GASPOutput>, GASPError>;
825
826    /// Hydrate a GASP node with transaction data, proof, and optional metadata.
827    async fn hydrate_gasp_node(
828        &self,
829        graph_id: &str,
830        txid: &str,
831        output_index: u32,
832        metadata: bool,
833    ) -> Result<GASPNode, GASPError>;
834
835    /// Determine which input transactions are needed to validate this node.
836    /// Returns None if no additional inputs are needed.
837    async fn find_needed_inputs(
838        &self,
839        node: &GASPNode,
840    ) -> Result<Option<GASPNodeResponse>, GASPError>;
841
842    /// Append a node to a temporary graph being constructed during sync.
843    /// `spent_by` is the "txid.outputIndex" of the node that spent this one (if not the root).
844    async fn append_to_graph(
845        &self,
846        node: &GASPNode,
847        spent_by: Option<&str>,
848    ) -> Result<(), GASPError>;
849
850    /// Validate that the graph's anchor (root) references only proven or known transactions.
851    async fn validate_graph_anchor(&self, graph_id: &str) -> Result<(), GASPError>;
852
853    /// Finalize a graph — commit the synced UTXO and its ancestors to permanent storage.
854    async fn finalize_graph(&self, graph_id: &str) -> Result<(), GASPError>;
855
856    /// Discard a temporary graph that failed validation.
857    async fn discard_graph(&self, graph_id: &str) -> Result<(), GASPError>;
858
859    /// The keys of the deferred graphs of this (peer, topic), lowest score
860    /// first (bsv-low #555). REQUIRED, as the three below (the lens fold's
861    /// M2): a storage that keeps no records says so here, by answering none
862    /// and refusing every save, and its graphs past the budget then fail.
863    async fn load_deferred_graphs(&self) -> Result<Vec<DeferredGraphKey>, GASPError>;
864
865    /// The record of the graph rooted at `outpoint`, if one is held.
866    async fn get_deferred_graph(&self, outpoint: &str) -> Result<Option<DeferredGraph>, GASPError>;
867
868    /// Save (replace) the record of one deferred graph.
869    async fn save_deferred_graph(
870        &self,
871        record: &DeferredGraph,
872    ) -> Result<DeferredGraphSave, GASPError>;
873
874    /// Delete the record of the graph rooted at `outpoint`.
875    async fn delete_deferred_graph(&self, outpoint: &str) -> Result<(), GASPError>;
876}
877
878// ============================================================================
879// GASPRemote trait
880// ============================================================================
881
882/// Communication interface with a foreign GASP peer.
883#[async_trait(?Send)]
884pub trait GASPRemote {
885    /// Send an initial request and get the peer's initial response.
886    async fn get_initial_response(
887        &self,
888        request: &GASPInitialRequest,
889    ) -> Result<GASPInitialResponse, GASPError>;
890
891    /// Send our initial response and get the peer's reply.
892    async fn get_initial_reply(
893        &self,
894        response: &GASPInitialResponse,
895    ) -> Result<GASPInitialReply, GASPError>;
896
897    /// Request a specific node from the peer.
898    async fn request_node(
899        &self,
900        graph_id: &str,
901        txid: &str,
902        output_index: u32,
903        metadata: bool,
904    ) -> Result<GASPNode, GASPError>;
905
906    /// Submit a node to the peer and get back which inputs they need.
907    async fn submit_node(&self, node: &GASPNode) -> Result<Option<GASPNodeResponse>, GASPError>;
908}
909
910// ============================================================================
911// GASPRemoteFactory trait
912// ============================================================================
913
914/// Factory for creating `GASPRemote` instances for specific peers.
915///
916/// The Engine holds an optional factory. Platform-specific crates (like
917/// overlay-cloudflare) provide an implementation that creates HTTP-based
918/// remotes.
919pub trait GASPRemoteFactory {
920    /// Create a `GASPRemote` for the given peer URL and topic.
921    fn create_remote(&self, peer_url: &str, topic: &str) -> Box<dyn GASPRemote>;
922}
923
924// ============================================================================
925// AncestorFetcher trait (OPT-IN, OFF by default)
926// ============================================================================
927
928/// Optional chain-backed ancestor fetcher for GASP ingest self-healing.
929///
930/// **OPT-IN / OFF BY DEFAULT.** When a peer (e.g. legacy TS beta) cannot serve
931/// a needed ancestor node during graph ingest (it returns HTTP 400 "Incomplete
932/// SPV data!" because its stored BEEF is minimal), the orchestrator normally
933/// abandons that graph. If — and ONLY if — an `AncestorFetcher` is configured,
934/// `process_incoming_node` falls back to fetching the ancestor's raw tx from
935/// chain (e.g. WhatsOnChain) and synthesizing a no-proof `GASPNode` that the
936/// existing recursion stitches into the graph.
937///
938/// This is a deliberate one-time-migration escape hatch. Production must NOT
939/// configure a fetcher: when the fetcher is `None` the ingest path is
940/// byte-identical to today (peer errors propagate / are swallowed upstream).
941///
942/// The orchestrator stays platform-agnostic; the concrete WoC/`worker::Fetch`
943/// implementation lives in the platform crate (e.g. zanaadu `overlay`), never
944/// in this engine crate (no `reqwest`/`std::time` — must stay wasm-clean).
945#[async_trait(?Send)]
946pub trait AncestorFetcher {
947    /// Fetch an ancestor transaction by txid from chain.
948    ///
949    /// Returns the raw tx hex plus, when the ancestor is mined, its BUMP merkle
950    /// proof hex. A proven node ends the walk unless its topic manager names
951    /// inputs needed for overlay history. Managers naming nothing still stop
952    /// at the first proven layer. Managers needing history select those inputs
953    /// instead of walking every funding/fee input back toward coinbase.
954    ///
955    /// The transaction DAG (no cycles), `seen` keyed by `txid.outputIndex`,
956    /// the already-known strip, and the manager naming nothing at genesis
957    /// bound the walk. There is no node cap, matching the reference's undefined
958    /// `maxNodesInGraph` in `Engine.startGASPSync`. A per-peer budget drops the
959    /// sync future at its deadline: the graphs finalized before it stay
960    /// admitted (bsv-low #552, [`FinalizedGraphHook`]), the graph in flight
961    /// is lost whole and walked again by the next sync, unless a per-graph
962    /// budget is set (bsv-low #555, `Engine::set_graph_budget`): then a walk
963    /// past either budget is deferred with its fetched nodes kept and resumed
964    /// by the next sync, so ONE graph deeper than a pass converges over
965    /// passes. Without it such a graph still never completes.
966    ///
967    /// The implementation MUST verify that the returned bytes hash to the
968    /// requested `txid` before returning them (integrity check) so a
969    /// malicious/garbled response cannot inject a forged ancestor.
970    async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError>;
971
972    /// Fetch (and chaintracks-verify) ONLY the merkle BUMP for `txid`, WITHOUT
973    /// fetching the raw tx.
974    ///
975    /// The proof-completion passes (`complete_missing_proofs`, the LOW pot-store
976    /// tick) already hold the raw in the stored BEEF, so the raw fetch that
977    /// [`Self::fetch_ancestor`] performs is a redundant network round-trip there
978    /// — and a free-tier WhatsOnChain raw fetch 429s (#192/#193). This method is
979    /// the raw-free path.
980    ///
981    /// Default: delegate to `fetch_ancestor` and drop the raw, so a fetcher that
982    /// only implements `fetch_ancestor` keeps working unchanged. The production
983    /// `ChainProofFetcher` overrides it to skip the raw fetch entirely. Returns
984    /// `None` for an unmined/unverifiable tx (fail-closed), never an error.
985    async fn verified_proof_for(&self, txid: &str) -> Option<String> {
986        self.fetch_ancestor(txid).await.ok().and_then(|a| a.proof)
987    }
988
989    /// Like [`Self::verified_proof_for`], but a TRANSPORT/READ FAULT stays
990    /// distinguishable (bsv-low#304 gate M-5): `Err` = the proof source or
991    /// header source READ failed (e.g. a chaintracks call starved at the
992    /// invocation's subrequest wall) — retryable, NOT a chain verdict;
993    /// `Ok(None)` = honestly no verified proof yet. Money-relevant callers
994    /// (the spend-confirmation chaser) surface + count the fault so a
995    /// starved tick is visible instead of masquerading as "not mined yet".
996    /// Default: wrap `verified_proof_for` (which never faults) — existing
997    /// fetchers keep their semantics unchanged.
998    async fn verified_proof_for_detailed(&self, txid: &str) -> Result<Option<String>, String> {
999        Ok(self.verified_proof_for(txid).await)
1000    }
1001
1002    /// bsv-low #451 slice B (2026-09-17): like [`Self::verified_proof_for_detailed`], but the ladder asks EVERY
1003    /// rung — no per-pass short-circuit on the broadcaster's "held unmined" word. The completion passes may stop
1004    /// early (a fresh unmined word from Arcade means no courier holds a proof yet; the next tick asks again), but
1005    /// a caller that ACTS on `Ok(None)` this pass — the reorg re-anchor, which demotes a refuted row it could not
1006    /// re-prove; the unmined-ancestry walk, which treats an unproven ancestor as a branch to keep walking — must
1007    /// hear the couriers too (the delta-verify's LOW-A). Default: the detailed ask (a fetcher with no short-circuit
1008    /// has nothing to skip).
1009    async fn verified_proof_for_exhaustive(&self, txid: &str) -> Result<Option<String>, String> {
1010        self.verified_proof_for_detailed(txid).await
1011    }
1012
1013    /// bsv-low M19B-G1 round 2 (review MED-2): the ladder's REMAINING
1014    /// per-invocation budget, when it keeps one (`None` = unbounded, or not
1015    /// known). A budgeted ladder answers `Ok(None)` to every ask once its
1016    /// budget is spent, which a caller must not read as "no courier serves a
1017    /// proof": it checks this first and leaves the row for a fresh budget.
1018    fn budget_remaining(&self) -> Option<u32> {
1019        None
1020    }
1021
1022    /// Verify that `bump_hex` is a chaintracks-valid merkle proof for `txid`.
1023    ///
1024    /// Used by proof completion to re-check a STORED structural bump before
1025    /// trusting its `has_proof` flag: a structural bump admitted WITHOUT SPV (or
1026    /// forged) must never be latched-proven and trimmed on (#192/#193). Default:
1027    /// fail-closed `false` — a fetcher with no header source can prove nothing.
1028    /// The production `ChainProofFetcher` overrides it against chaintracks.
1029    async fn verify_proof(&self, txid: &str, bump_hex: &str) -> bool {
1030        let _ = (txid, bump_hex);
1031        false
1032    }
1033
1034    /// Which txid spends `txid:vout`, per an EXTERNAL INDEXER — a HINT, never
1035    /// a verdict (bsv-low 2026-08-18, the displaced-spender reconcile). The
1036    /// caller must independently prove the hint before acting on it:
1037    /// [`Self::spender_binding_raw`] (the bytes really spend the outpoint)
1038    /// and [`Self::verified_proof_for_detailed`] (the spender is MINED, root
1039    /// verified against our PoW-anchored headers). An indexer can therefore
1040    /// only ever point at the REAL confirmed spender — pointing anywhere else
1041    /// fails one of the two proofs and changes nothing.
1042    ///
1043    /// Default `Ok(None)` — unknown — so every existing implementation keeps
1044    /// compiling AND keeps today's behavior (no resolution, fail-closed).
1045    async fn resolve_spender(&self, _txid: &str, _vout: u32) -> Result<Option<String>, String> {
1046        Ok(None)
1047    }
1048
1049    /// bsv-low (2026-09-04) — the SCRIPTHASH-HISTORY rung: the txids a
1050    /// courier's script-history index lists for `scripthash_le_hex` OTHER
1051    /// than `funding_txid` (newest first, a few at most). A pot's covenant
1052    /// script is unique to that pot, so its history is exactly
1053    /// `[funding, spender]` and the answer is the spender CANDIDATE; the
1054    /// caller BINDS it with [`spender_binding_raw`] before believing it. This
1055    /// rung survives a provider retiring its per-output spend endpoint
1056    /// (Bitails, pruned mode, 2026-09). `Err` = every rung faulted; an empty
1057    /// `Ok` = the indexes answered and list no other tx. Default: no index.
1058    ///
1059    /// [`spender_binding_raw`]: AncestorFetcher::spender_binding_raw
1060    async fn resolve_spender_by_script(
1061        &self,
1062        _scripthash_le_hex: &str,
1063        _funding_txid: &str,
1064    ) -> Result<Vec<String>, String> {
1065        Ok(Vec::new())
1066    }
1067
1068    /// bsv-low (2026-09-04): an output's LOCKING SCRIPT by outpoint from a
1069    /// courier's output index (BananaBlocks `/txo/{txid}/{vout}`) — the
1070    /// scripthash rung's script source when no BEEF is stored for the row.
1071    /// `Ok(None)` = the index never saw it. Default: no such index.
1072    async fn output_script_hint(&self, _txid: &str, _vout: u32) -> Result<Option<Vec<u8>>, String> {
1073        Ok(None)
1074    }
1075
1076    /// `spender`'s raw tx hex — fetched CONTENT-ADDRESSED (the bytes must
1077    /// hash to `spender`) — returned iff those bytes carry an input spending
1078    /// exactly `txid:vout`. This is the binding that turns
1079    /// [`Self::resolve_spender`]'s hint into a fact, and the caller gets the
1080    /// PROVEN bytes back so it can derive further facts from them (bytes
1081    /// finality, a durable BEEF) without a second fetch. `Ok(None)` = the
1082    /// bytes were read and do NOT bind (a lying or garbled hint — refuse);
1083    /// `Err` = could not read (a fault, never a verdict). Default `Ok(None)`:
1084    /// fail-closed, an unbindable hint is a dead hint.
1085    async fn spender_binding_raw(
1086        &self,
1087        _spender: &str,
1088        _txid: &str,
1089        _vout: u32,
1090    ) -> Result<Option<String>, String> {
1091        Ok(None)
1092    }
1093}
1094
1095/// An ancestor transaction fetched from chain: its raw tx hex plus an optional
1096/// BUMP merkle proof hex (present when the tx is mined).
1097#[derive(Debug, Clone)]
1098pub struct FetchedAncestor {
1099    /// Raw transaction hex.
1100    pub raw_tx: String,
1101    /// BUMP merkle proof hex, if the ancestor is mined. When `Some`, the
1102    /// synthesized node ends the walk unless its topic manager names inputs.
1103    pub proof: Option<String>,
1104}
1105
1106// ============================================================================
1107// FinalizedGraphHook (bsv-low #552)
1108// ============================================================================
1109
1110/// Called by [`GASPSync::sync`] after EVERY incoming UTXO whose graph was
1111/// completed (finalized, or refused by the anchor check and discarded), before
1112/// the next UTXO is asked for.
1113///
1114/// The reference submits inside `finalizeGraph`, so a graph is admitted the
1115/// moment it is finalized. Here `OverlayGASPStorage::finalize_graph` only
1116/// pushes to a sink (it holds a `Storage`, not the engine), and the engine
1117/// used to drain that sink after the WHOLE sync: a sync dropped at its
1118/// per-peer budget admitted nothing. With a hook the engine drains the sink
1119/// per graph, inside the raced future, so what was finalized before the
1120/// deadline stays admitted and the next sync's known-UTXO skip and known-input
1121/// strip resume from it.
1122///
1123/// An `Err` FAILS THAT UTXO, exactly as a failed ingest does: the gap guard
1124/// keeps the cursor below it and the next sync asks for it again. The engine
1125/// answers `Err` when a finalize submit did not land (the lens fold's
1126/// MEDIUM-1). It cannot fail the sync. While the hook runs,
1127/// [`GASPSync::completed_cursor`] still sits BELOW the UTXO just completed,
1128/// so a sync dropped inside the hook asks for that UTXO again.
1129#[async_trait(?Send)]
1130pub trait FinalizedGraphHook {
1131    /// One incoming UTXO's graph was completed; drain what it finalized.
1132    async fn graph_completed(&self) -> Result<(), GASPError>;
1133}
1134
1135// ============================================================================
1136// GASP orchestrator
1137// ============================================================================
1138
1139/// GASP sync orchestrator.
1140///
1141/// Coordinates between local `GASPStorage` and a `GASPRemote` peer to
1142/// synchronize overlay UTXOs. Supports paginated sync and unidirectional mode.
1143///
1144/// The lifetime `'a` allows the storage and remote to borrow from their
1145/// environment (e.g., `OverlayGASPStorage` borrows from the Engine's storage).
1146pub struct GASPSync<'a> {
1147    storage: Box<dyn GASPStorage + 'a>,
1148    remote: Box<dyn GASPRemote + 'a>,
1149    /// Score of last successful interaction with this peer.
1150    pub last_interaction: u64,
1151    /// If true, only pull from remote — don't push local UTXOs.
1152    pub unidirectional: bool,
1153    log_prefix: String,
1154    /// OPT-IN / OFF BY DEFAULT chain-backed ancestor fetcher. When `None`
1155    /// (the default), a peer's `request_node` error propagates and the graph
1156    /// is abandoned upstream, except for an input NAMED by a proven node's
1157    /// topic manager that the peer answers it does not hold
1158    /// (`GASPError::NodeNotFound`), whose branch is pruned (the D8 decoy
1159    /// rule). When `Some`, ancestors come from chain instead of the peer and
1160    /// nothing is pruned: every fetcher error abandons the graph.
1161    ancestor_fetcher: Option<std::rc::Rc<dyn AncestorFetcher + 'a>>,
1162    /// Manager-named inputs whose branch was PRUNED because the peer answered
1163    /// that it does not hold them (zanaadu-v2 #314, D8), keyed
1164    /// `txid.outputIndex`. One entry is one failed round trip: an outpoint in
1165    /// this set is never asked for again in this sync, whichever parent or
1166    /// graph names it. Cleared at the start of each `sync`.
1167    pruned: std::cell::RefCell<std::collections::HashSet<String>>,
1168    /// Distinct outpoints pruned in the last `sync` (the size of `pruned`).
1169    pruned_inputs: std::cell::Cell<u64>,
1170    /// Graphs REFUSED by `validate_graph_anchor` and discarded in the last
1171    /// `sync` (bsv-low #551). Cleared at the start of each `sync`.
1172    discarded_graphs: std::cell::Cell<u64>,
1173    /// Graphs whose anchor walk could not run within the budget and were
1174    /// HELD (the E592 lens fold): [`GASPSync::anchor_walk_held`].
1175    anchor_walk_held: std::cell::Cell<u64>,
1176    /// Called after every completed incoming UTXO (bsv-low #552). `None`
1177    /// (the default): nothing is called and `sync` runs as it always did.
1178    finalized_hook: Option<Box<dyn FinalizedGraphHook + 'a>>,
1179    /// See [`Self::completed_cursor`].
1180    completed_cursor: u64,
1181    /// See [`Self::graph_in_flight`].
1182    graph_in_flight: bool,
1183    /// The per-graph budget (bsv-low #555). `None` (the default): no graph
1184    /// is deferred and the walk is the one before #555.
1185    graph_budget: Option<GraphBudget<'a>>,
1186    /// The roots of this (peer, topic)'s records not yet resumed in this
1187    /// sync (their keys; a record is read when its UTXO is served).
1188    deferred: std::cell::RefCell<std::collections::HashSet<String>>,
1189    /// ONE deadline for every resume of the sync (the lens fold's M1), made
1190    /// at the first resume.
1191    resume_deadline: std::cell::RefCell<Option<crate::engine::SleepFuture>>,
1192    /// What every resume of the sync may still spend together (M1).
1193    resume_left: std::cell::Cell<PassCap>,
1194    /// Calls made by the sync's walks so far.
1195    calls_made: std::cell::Cell<u64>,
1196    /// Bytes served to the sync's walks so far (bsv-low #586).
1197    bytes_fetched: std::cell::Cell<u64>,
1198    /// Nodes appended by the sync's walks so far (bsv-low #586).
1199    nodes_appended: std::cell::Cell<u64>,
1200    /// How many records this (peer, topic) holds in storage.
1201    held_records: std::cell::Cell<usize>,
1202    /// The walk of the graph in hand (see [`Walk`]).
1203    walk: std::cell::RefCell<Option<Walk>>,
1204    /// See [`Self::deferral_stats`].
1205    stats: std::cell::RefCell<DeferralStats>,
1206    /// See [`Self::graphs_attempted`].
1207    graphs_attempted: std::cell::Cell<u64>,
1208}
1209
1210impl<'a> GASPSync<'a> {
1211    /// Create a new GASP sync orchestrator.
1212    ///
1213    /// The ancestor fetcher is OFF by default. Use `with_ancestor_fetcher` to
1214    /// opt in to chain-backed ancestry hydration (one-time-migration only).
1215    pub fn new(
1216        storage: Box<dyn GASPStorage + 'a>,
1217        remote: Box<dyn GASPRemote + 'a>,
1218        last_interaction: u64,
1219        log_prefix: impl Into<String>,
1220        unidirectional: bool,
1221    ) -> Self {
1222        Self {
1223            storage,
1224            remote,
1225            last_interaction,
1226            unidirectional,
1227            log_prefix: log_prefix.into(),
1228            ancestor_fetcher: None,
1229            pruned: std::cell::RefCell::new(std::collections::HashSet::new()),
1230            pruned_inputs: std::cell::Cell::new(0),
1231            discarded_graphs: std::cell::Cell::new(0),
1232            anchor_walk_held: std::cell::Cell::new(0),
1233            finalized_hook: None,
1234            completed_cursor: last_interaction,
1235            graph_in_flight: false,
1236            graph_budget: None,
1237            deferred: std::cell::RefCell::new(std::collections::HashSet::new()),
1238            resume_deadline: std::cell::RefCell::new(None),
1239            resume_left: std::cell::Cell::new(PassCap::NONE),
1240            calls_made: std::cell::Cell::new(0),
1241            bytes_fetched: std::cell::Cell::new(0),
1242            nodes_appended: std::cell::Cell::new(0),
1243            held_records: std::cell::Cell::new(0),
1244            walk: std::cell::RefCell::new(None),
1245            stats: std::cell::RefCell::new(DeferralStats::default()),
1246            graphs_attempted: std::cell::Cell::new(0),
1247        }
1248    }
1249
1250    /// Bound each graph's walk by `budget` and DEFER a graph past it
1251    /// (bsv-low #555): its partial walk is saved through
1252    /// [`GASPStorage::save_deferred_graph`], its UTXO is held below the
1253    /// cursor like a failed one, the sync goes on to the next UTXO, and the
1254    /// next sync that is served the UTXO RESUMES the walk from the record,
1255    /// asking only what is still pending. Without a budget nothing is
1256    /// deferred and `sync` is unchanged.
1257    #[must_use]
1258    pub fn with_graph_budget(mut self, budget: GraphBudget<'a>) -> Self {
1259        self.graph_budget = Some(budget);
1260        self
1261    }
1262
1263    /// What the last `sync` did with deferred graphs (bsv-low #555).
1264    pub fn deferral_stats(&self) -> DeferralStats {
1265        self.stats.borrow().clone()
1266    }
1267
1268    /// How many UTXOs the sync set out to ingest (a new walk, a resume, or a
1269    /// record held back): the peer served it work it did not hold. A sync
1270    /// that attempted none was quiet (bsv-low #555, the delta fold's D-M2:
1271    /// a quiet sync never counts toward [`PEER_YIELDLESS_SYNCS_ALLOWED`]).
1272    pub fn graphs_attempted(&self) -> u64 {
1273        self.graphs_attempted.get()
1274    }
1275
1276    /// Have `hook` called after every completed incoming UTXO (bsv-low #552,
1277    /// see [`FinalizedGraphHook`]). Without one `sync` is unchanged.
1278    #[must_use]
1279    pub fn with_finalized_graph_hook(mut self, hook: Box<dyn FinalizedGraphHook + 'a>) -> Self {
1280        self.finalized_hook = Some(hook);
1281        self
1282    }
1283
1284    /// The cursor that is safe to persist if `sync` stopped RIGHT NOW (its
1285    /// future dropped at a deadline; bsv-low #552). It is the gap guard's
1286    /// rule applied to unfinished work: strictly below the lowest score of
1287    /// any UTXO of the current page not yet completed (the one in flight
1288    /// included) and of any UTXO whose ingest failed, never below the cursor
1289    /// the sync entered with. The remote serves `score >= since`, so the next
1290    /// sync is served everything this one did not finish. After a completed
1291    /// `sync` it equals `last_interaction`.
1292    ///
1293    /// `last_interaction` itself is NOT safe after a drop: it is raised to
1294    /// each UTXO's score BEFORE that UTXO is ingested, for pagination.
1295    pub fn completed_cursor(&self) -> u64 {
1296        self.completed_cursor
1297    }
1298
1299    /// Whether a graph was being fetched, walked or anchor-checked when `sync`
1300    /// last stopped: `true` only after a `sync` future dropped mid-graph
1301    /// (bsv-low #552). That graph's nodes died with the storage adapter;
1302    /// nothing of it was finalized.
1303    pub fn graph_in_flight(&self) -> bool {
1304        self.graph_in_flight
1305    }
1306
1307    /// `cursor` capped strictly below `lowest_unfinished`, floored at `floor`:
1308    /// the gap guard's arithmetic, shared with [`Self::completed_cursor`].
1309    fn capped_below(cursor: u64, lowest_unfinished: Option<u64>, floor: u64) -> u64 {
1310        match lowest_unfinished {
1311            Some(score) => cursor.min(score.saturating_sub(1).max(floor)),
1312            None => cursor,
1313        }
1314    }
1315
1316    /// How many graphs this orchestrator's last `sync` discarded because
1317    /// `validate_graph_anchor` REFUSED them (a root that does not verify, or
1318    /// one the topic manager does not admit at the end of the replay; bsv-low
1319    /// #551). Nothing of a discarded graph is finalized. As in the reference
1320    /// a refused graph is not a failed UTXO: the cursor advances past it. A
1321    /// graph whose anchor could not be CHECKED
1322    /// (`GASPError::AnchorUnavailable`: a tracker or storage fault, a topic
1323    /// manager error in the replay) is not counted here: that one fails its
1324    /// UTXO and is asked for again.
1325    pub fn discarded_graphs(&self) -> u64 {
1326        self.discarded_graphs.get()
1327    }
1328
1329    /// How many graphs this orchestrator's last `sync` HELD because their
1330    /// anchor's script walk could not run within the engine's walk budget
1331    /// (`GASPError::AnchorWalkHeld`; bsv-low #592, the E592 lens fold): not
1332    /// finalized, the UTXO failed and the cursor kept below it, until the
1333    /// peer serves the root proven. Not counted in `discarded_graphs`.
1334    pub fn anchor_walk_held(&self) -> u64 {
1335        self.anchor_walk_held.get()
1336    }
1337
1338    /// How many manager-named inputs this orchestrator pruned because the
1339    /// peer answered that it does not hold them (the D8 decoy rule, see
1340    /// `process_incoming_node`). Counted per DISTINCT outpoint per `sync`
1341    /// call of this orchestrator, which talks to ONE peer: the same decoy
1342    /// seen through two peers is counted by each. A pruned branch is NOT a
1343    /// failed UTXO: the graph completes with what it has and the cursor
1344    /// advances.
1345    pub fn pruned_inputs(&self) -> u64 {
1346        self.pruned_inputs.get()
1347    }
1348
1349    /// Opt in to chain-backed ancestor hydration (OFF by default).
1350    ///
1351    /// When set, a peer's inability to serve a needed ancestor during ingest
1352    /// triggers a fallback fetch of that ancestor's raw tx from chain. This is
1353    /// a deliberate one-time-migration escape hatch — production should NOT
1354    /// call this. Without it, behavior is unchanged.
1355    #[must_use]
1356    pub fn with_ancestor_fetcher(
1357        mut self,
1358        fetcher: Option<std::rc::Rc<dyn AncestorFetcher + 'a>>,
1359    ) -> Self {
1360        self.ancestor_fetcher = fetcher;
1361        self
1362    }
1363
1364    /// Run the sync protocol with the remote peer.
1365    ///
1366    /// 1. Request remote's UTXOs since last interaction (paginated)
1367    /// 2. For each unknown UTXO, request the full graph and ingest it
1368    /// 3. If bidirectional, push our unknown UTXOs to the remote
1369    pub async fn sync(&mut self, limit: Option<u64>) -> Result<(), GASPError> {
1370        info!(
1371            "{} Starting sync. last_interaction={}",
1372            self.log_prefix, self.last_interaction
1373        );
1374        self.pruned.borrow_mut().clear();
1375        self.pruned_inputs.set(0);
1376        self.discarded_graphs.set(0);
1377        self.anchor_walk_held.set(0);
1378        self.completed_cursor = self.last_interaction;
1379        self.graph_in_flight = false;
1380        self.walk.borrow_mut().take();
1381        *self.stats.borrow_mut() = DeferralStats::default();
1382        self.deferred.borrow_mut().clear();
1383        self.held_records.set(0);
1384        self.resume_deadline.borrow_mut().take();
1385        self.resume_left.set(
1386            self.graph_budget
1387                .as_ref()
1388                .map_or(PassCap::NONE, GraphBudget::pass),
1389        );
1390        // bsv-low #555: the deferred graphs of this (peer, topic), resumed
1391        // as the peer serves their UTXOs again (the cursor was held below
1392        // them). Only their keys: a record is read when its UTXO is served
1393        // (the lens fold's L3). A read fault resumes nothing this sync: those
1394        // UTXOs are walked from their roots and their records replaced, and
1395        // the (peer, topic) is taken as FULL, so no new record is saved over
1396        // a count not known (the lens fold's L6).
1397        if self.graph_budget.is_some() {
1398            match self.storage.load_deferred_graphs().await {
1399                Ok(keys) => {
1400                    self.held_records.set(keys.len());
1401                    self.deferred
1402                        .borrow_mut()
1403                        .extend(keys.into_iter().map(|k| k.outpoint));
1404                }
1405                Err(e) => {
1406                    self.held_records.set(DEFERRED_GRAPHS_PER_PEER_TOPIC);
1407                    warn!(
1408                        "{} Could not read the deferred graphs (bsv-low #555): {}",
1409                        self.log_prefix, e
1410                    );
1411                }
1412            }
1413        }
1414
1415        // Track what we already know
1416        let local_utxos = self.storage.find_known_utxos(0, None).await?;
1417        let mut known_outpoints: std::collections::HashSet<String> = local_utxos
1418            .iter()
1419            .map(|u| format!("{}.{}", u.txid, u.output_index))
1420            .collect();
1421        let mut shared_outpoints: std::collections::HashSet<String> =
1422            std::collections::HashSet::new();
1423
1424        // The cursor we entered with. The persisted cursor must never regress
1425        // below it (a failed RE-ingest of an already-synced range is not a new
1426        // gap), and the gap-guard below is floored at it.
1427        let initial_interaction = self.last_interaction;
1428        // Lowest score of any UTXO whose graph ingest FAILED this run. The
1429        // in-loop `last_interaction` advance below is by max-seen-score so
1430        // pagination terminates, but a transient ingest failure must NOT let the
1431        // PERSISTED cursor skip past that output — else the peer never re-serves
1432        // it (next `since` excludes scores below the cursor) and it is stranded
1433        // forever. After the loop we cap the cursor strictly below this score so
1434        // the next sync re-requests the failed graph. (Without this, a single
1435        // blipped graph fetch silently drops that UTXO from continuous sync.)
1436        let mut min_failed_score: Option<u64> = None;
1437        // The UTXOs whose ingest FAILED this run (bsv-low #554). The cursor
1438        // moves to a UTXO's score before its ingest and the responder serves
1439        // `score >= since`, so the row at a page boundary is served again on
1440        // the next page of this same sync. A completed one is in
1441        // `shared_outpoints` and skipped; a failed one was in no set and was
1442        // walked a second time, at twice the requests and twice the per-peer
1443        // budget. An addition to the reference: `GASP.ts sync` keeps only
1444        // `sharedOutpoints` (added on success), so a failed UTXO served
1445        // again by a later page is ingested again there. Here a UTXO is
1446        // ingested at most once per sync; the retry is the next sync's, which
1447        // the gap guard below makes sure is served it. Kept apart from
1448        // `shared_outpoints`, which also decides what a bidirectional sync
1449        // pushes.
1450        let mut failed_outpoints: std::collections::HashSet<String> =
1451            std::collections::HashSet::new();
1452
1453        // Paginated pull from remote
1454        loop {
1455            let cursor_before_page = self.last_interaction;
1456            // Every UTXO seen so far is completed or failed: were the sync
1457            // dropped inside the page request below, this is what it did.
1458            self.completed_cursor =
1459                Self::capped_below(self.last_interaction, min_failed_score, initial_interaction);
1460            let request = GASPInitialRequest {
1461                version: GASP_VERSION,
1462                since: self.last_interaction,
1463                limit,
1464            };
1465            let response = self.remote.get_initial_response(&request).await?;
1466            let page_size = response.utxo_list.len();
1467
1468            info!(
1469                "{} Processing page with {} UTXOs (since: {})",
1470                self.log_prefix, page_size, response.since
1471            );
1472
1473            // lowest_ahead[i]: the lowest score among this page's UTXOs from
1474            // i on, for `completed_cursor` (a page is not assumed sorted).
1475            let mut lowest_ahead: Vec<u64> =
1476                response.utxo_list.iter().map(|u| u.score as u64).collect();
1477            for i in (0..lowest_ahead.len().saturating_sub(1)).rev() {
1478                lowest_ahead[i] = lowest_ahead[i].min(lowest_ahead[i + 1]);
1479            }
1480
1481            for (position, utxo) in response.utxo_list.iter().enumerate() {
1482                // Track highest score for pagination
1483                if utxo.score as u64 > self.last_interaction {
1484                    self.last_interaction = utxo.score as u64;
1485                }
1486
1487                let outpoint = format!("{}.{}", utxo.txid, utxo.output_index);
1488                if known_outpoints.contains(&outpoint) {
1489                    // A deferred graph whose UTXO the node now holds (it
1490                    // landed another way): nothing to resume.
1491                    if self.deferred.borrow_mut().remove(&outpoint) {
1492                        self.drop_record(&outpoint, DropReason::Held).await;
1493                    }
1494                    shared_outpoints.insert(outpoint.clone());
1495                    known_outpoints.remove(&outpoint);
1496                } else if !shared_outpoints.contains(&outpoint)
1497                    && !failed_outpoints.contains(&outpoint)
1498                {
1499                    // New UTXO — request and ingest the graph
1500                    let lowest_unfinished = min_failed_score
1501                        .map_or(lowest_ahead[position], |f| f.min(lowest_ahead[position]));
1502                    self.completed_cursor = Self::capped_below(
1503                        self.last_interaction,
1504                        Some(lowest_unfinished),
1505                        initial_interaction,
1506                    );
1507                    self.graph_in_flight = true;
1508                    let ingested = self.ingest_utxo(utxo, &outpoint).await;
1509                    self.graph_in_flight = false;
1510                    let ingested = match ingested {
1511                        Ok(Ingested::Completed) => {
1512                            let landed = match &self.finalized_hook {
1513                                Some(hook) => hook.graph_completed().await,
1514                                None => Ok(()),
1515                            };
1516                            self.settle_completed(landed.is_ok()).await;
1517                            landed.map(|()| true)
1518                        }
1519                        Ok(Ingested::Deferred | Ingested::HeldBack) => Ok(false),
1520                        Err(e) => {
1521                            self.walk.borrow_mut().take();
1522                            Err(e)
1523                        }
1524                    };
1525                    match ingested {
1526                        Ok(true) => {
1527                            shared_outpoints.insert(outpoint);
1528                        }
1529                        // bsv-low #555: DEFERRED, its walk saved (or a
1530                        // record held back for the next sync, M1). Held
1531                        // below the cursor like a failed UTXO (the gap
1532                        // guard) and not walked again in this sync (#554);
1533                        // the next sync resumes it. The sync goes on.
1534                        Ok(false) => {
1535                            let s = utxo.score as u64;
1536                            min_failed_score = Some(min_failed_score.map_or(s, |cur| cur.min(s)));
1537                            failed_outpoints.insert(outpoint);
1538                        }
1539                        Err(e) => {
1540                            warn!(
1541                                "{} Error ingesting UTXO {}: {}",
1542                                self.log_prefix, outpoint, e
1543                            );
1544                            // Remember the lowest failed score so the persisted
1545                            // cursor cannot skip past it (see cap below).
1546                            let s = utxo.score as u64;
1547                            min_failed_score = Some(min_failed_score.map_or(s, |cur| cur.min(s)));
1548                            // Not again in this sync (bsv-low #554).
1549                            failed_outpoints.insert(outpoint);
1550                        }
1551                    }
1552                }
1553            }
1554
1555            // Pagination termination (bsv-low #291 gate findings M1/LOW-A):
1556            // keep paging on ANY page that advanced the score cursor —
1557            // with or without a requested limit. The old rule ("continue
1558            // only on a full page"; `None` never paged at all) silently
1559            // terminated after one page against a responder that CLAMPS
1560            // the page size (Engine::clamp_sync_limit clamps BOTH an
1561            // over-large and an ABSENT limit) — a clamped page is never
1562            // "full" by our limit, yet more rows remain. A non-advancing
1563            // page (empty, or only rows at scores we already hold — the
1564            // responder serves `score >= since`, so the boundary row is
1565            // re-served) is the true completion signal: no further request
1566            // can make progress.
1567            //
1568            // Known residual (gate LOW-B, documented not fixed): the
1569            // cursor is the bare GASP `since` score, so a tie group of
1570            // IDENTICAL scores at least as large as the responder's page
1571            // would end the run with that group's tail unreached (break on
1572            // non-advance — a silent gap, never a spin). A compound
1573            // (score, rowid) cursor would need a wire change to `since`,
1574            // which is out of bounds; unconstructible in practice — see
1575            // the responder-side note at
1576            // `D1Storage::find_utxos_for_topic`.
1577            if self.last_interaction == cursor_before_page {
1578                break;
1579            }
1580        }
1581
1582        // Gap-guard: if any graph ingest failed transiently, cap the cursor
1583        // strictly below the lowest failed score so the next sync re-pulls it
1584        // (the remote serves `score >= since`). Floored at `initial_interaction`
1585        // so the cursor never regresses — a failure within the already-synced
1586        // range is not a new gap and must not rewind the cursor.
1587        if let Some(failed) = min_failed_score {
1588            let cap = failed.saturating_sub(1).max(initial_interaction);
1589            if self.last_interaction > cap {
1590                warn!(
1591                    "{} Capping cursor {} -> {} (transient ingest failure at score {}); next sync will re-pull",
1592                    self.log_prefix, self.last_interaction, cap, failed
1593                );
1594                self.last_interaction = cap;
1595            }
1596        }
1597        self.completed_cursor = self.last_interaction;
1598
1599        // bsv-low #555: a record whose UTXO a sync that ran to its end was
1600        // not served (spent at the peer, or no longer listed): nothing will
1601        // resume it.
1602        let unserved: Vec<String> = self.deferred.borrow_mut().drain().collect();
1603        for outpoint in unserved {
1604            self.drop_record(&outpoint, DropReason::NotServed).await;
1605        }
1606
1607        // Bidirectional: push our UTXOs to remote
1608        if !self.unidirectional {
1609            // Find local UTXOs the remote doesn't have
1610            for utxo in &local_utxos {
1611                let outpoint = format!("{}.{}", utxo.txid, utxo.output_index);
1612                if !shared_outpoints.contains(&outpoint) {
1613                    match self.push_utxo(utxo).await {
1614                        Ok(()) => {}
1615                        Err(e) => {
1616                            warn!("{} Error pushing UTXO {}: {}", self.log_prefix, outpoint, e);
1617                        }
1618                    }
1619                }
1620            }
1621        }
1622
1623        info!("{} Sync completed!", self.log_prefix);
1624        Ok(())
1625    }
1626
1627    /// Request a UTXO's graph from remote and ingest it locally.
1628    ///
1629    /// With a per-graph budget (bsv-low #555, [`Self::with_graph_budget`]) a
1630    /// walk past it is DEFERRED (`Ok(Ingested::Deferred)`): its record is
1631    /// saved and the caller holds the UTXO below the cursor like a failed
1632    /// one. A record of this UTXO saved by an earlier sync is RESUMED: its
1633    /// nodes are appended again (no request) and only its pending inputs are
1634    /// asked. A RESUMED walk's error defers it again (the progress is kept),
1635    /// except the peer's definite "not held" for an input an UNPROVEN node
1636    /// needs (an SPV necessity, as in the reference): that one fails the UTXO
1637    /// and drops the record. A FRESH walk's error keeps no record and fails
1638    /// the UTXO, as before #555 (the delta fold's D-M1). On `Completed` the walk stays in hand until
1639    /// [`Self::settle_completed`] knows whether it landed.
1640    ///
1641    /// Every RESUME of one sync shares ONE per-graph budget (the lens fold's
1642    /// M1): one deadline, made at the first resume, and one call count. The
1643    /// cursor is held below the deferred UTXOs, so the peer serves them
1644    /// first; with a budget each, two of them took the whole per-peer budget
1645    /// and no UTXO above them was reached. Now the resumes of a pass take at
1646    /// most one graph's budget and the first new UTXO keeps its own. A record
1647    /// served once that budget is spent is HELD BACK untouched (no read, no
1648    /// pass counted) for the next sync.
1649    async fn ingest_utxo(&self, utxo: &GASPOutput, outpoint: &str) -> Result<Ingested, GASPError> {
1650        debug!("{} Requesting node for {}", self.log_prefix, outpoint);
1651        self.graphs_attempted.set(self.graphs_attempted.get() + 1);
1652        let Some(budget) = &self.graph_budget else {
1653            *self.walk.borrow_mut() =
1654                Some(Self::fresh_walk(utxo.score as u64, outpoint, PassCap::NONE));
1655            let walked = self.walk_graph(None).await;
1656            let walk = self.walk.borrow_mut().take();
1657            walked?;
1658            self.complete_graph(&Self::graph_id_of(walk.as_ref(), outpoint))
1659                .await?;
1660            return Ok(Ingested::Completed);
1661        };
1662
1663        if !self.deferred.borrow_mut().remove(outpoint) {
1664            let mut deadline = latched((budget.deadline)());
1665            return self
1666                .ingest_budgeted(utxo, outpoint, &mut deadline, budget.pass(), false)
1667                .await;
1668        }
1669        let mut deadline = self
1670            .resume_deadline
1671            .borrow_mut()
1672            .take()
1673            .unwrap_or_else(|| latched((budget.deadline)()));
1674        let cap = self.resume_left.get();
1675        if cap.spent() || Self::due(&mut deadline).await {
1676            *self.resume_deadline.borrow_mut() = Some(deadline);
1677            self.stats.borrow_mut().held_back += 1;
1678            info!(
1679                "{} Deferred graph {} held back: the resumes of this pass spent their budget (bsv-low #555)",
1680                self.log_prefix, outpoint
1681            );
1682            return Ok(Ingested::HeldBack);
1683        }
1684        let before = (
1685            self.calls_made.get(),
1686            self.bytes_fetched.get(),
1687            self.nodes_appended.get(),
1688        );
1689        let ingested = self
1690            .ingest_budgeted(utxo, outpoint, &mut deadline, cap, true)
1691            .await;
1692        let narrowed = |spent: u64| u32::try_from(spent).unwrap_or(u32::MAX);
1693        self.resume_left.set(PassCap {
1694            calls: cap
1695                .calls
1696                .saturating_sub(narrowed(self.calls_made.get() - before.0)),
1697            bytes: cap
1698                .bytes
1699                .saturating_sub(self.bytes_fetched.get() - before.1),
1700            nodes: cap
1701                .nodes
1702                .saturating_sub(narrowed(self.nodes_appended.get() - before.2)),
1703        });
1704        *self.resume_deadline.borrow_mut() = Some(deadline);
1705        ingested
1706    }
1707
1708    /// Whether `deadline` has fallen due (polled once, with the task's own
1709    /// waker).
1710    async fn due(deadline: &mut crate::engine::SleepFuture) -> bool {
1711        std::future::poll_fn(|cx| std::task::Poll::Ready(deadline.as_mut().poll(cx).is_ready()))
1712            .await
1713    }
1714
1715    /// A walk from the root `outpoint`, nothing fetched yet.
1716    fn fresh_walk(score: u64, outpoint: &str, cap: PassCap) -> Walk {
1717        Walk {
1718            record: DeferredGraph {
1719                peer: String::new(),
1720                topic: String::new(),
1721                outpoint: outpoint.to_string(),
1722                score,
1723                nodes: Vec::new(),
1724                pending: vec![PendingInput {
1725                    outpoint: outpoint.to_string(),
1726                    graph_id: outpoint.to_string(),
1727                    metadata: true,
1728                    spent_by: None,
1729                    parent_proven: false,
1730                }],
1731                calls: 0,
1732                passes: 0,
1733                reason: String::new(),
1734                configured: false,
1735                idle_faults: 0,
1736            },
1737            seen: std::collections::HashSet::new(),
1738            calls_this_pass: 0,
1739            bytes_this_pass: 0,
1740            nodes_this_pass: 0,
1741            resumed: false,
1742            cap,
1743            nodes_at_start: 0,
1744            counted: false,
1745            gone_on: false,
1746            half: None,
1747        }
1748    }
1749
1750    /// One pass of a graph's walk under the per-graph budget: `deadline`,
1751    /// and `cap` (calls, bytes, nodes). `from_record`: a record of it is held (its key was
1752    /// loaded); it is read now.
1753    async fn ingest_budgeted(
1754        &self,
1755        utxo: &GASPOutput,
1756        outpoint: &str,
1757        deadline: &mut crate::engine::SleepFuture,
1758        cap: PassCap,
1759        from_record: bool,
1760    ) -> Result<Ingested, GASPError> {
1761        let score = utxo.score as u64;
1762        // A read fault fails the UTXO and keeps the record for the next sync.
1763        let record = if from_record {
1764            let record = self.storage.get_deferred_graph(outpoint).await?;
1765            if record.is_none() {
1766                // Deleted since the keys were read (the worker's sweep).
1767                self.held_records
1768                    .set(self.held_records.get().saturating_sub(1));
1769            }
1770            record
1771        } else {
1772            None
1773        };
1774        let walk = match record {
1775            None => {
1776                let mut walk = Self::fresh_walk(score, outpoint, cap);
1777                // D2-L1: the half-budget clock starts with the walk (polled
1778                // once now, so a timer that starts on its first poll runs).
1779                if let Some(half) = self
1780                    .graph_budget
1781                    .as_ref()
1782                    .and_then(|b| b.half_deadline.as_ref())
1783                {
1784                    let mut half = latched(half());
1785                    let _ = Self::due(&mut half).await;
1786                    walk.half = Some(half);
1787                }
1788                walk
1789            }
1790            Some(record) if record.passes >= DEFERRED_GRAPH_MAX_PASSES => {
1791                let passes = record.passes;
1792                self.drop_record(outpoint, DropReason::MaxPasses).await;
1793                return Err(GASPError::Other(format!(
1794                    "deferred graph {outpoint} dropped after {passes} passes (bsv-low #555)"
1795                )));
1796            }
1797            Some(record) => {
1798                self.stats.borrow_mut().resumed += 1;
1799                info!(
1800                    "{} Resuming deferred graph {} (bsv-low #555): {} nodes, {} pending, {} calls over {} passes",
1801                    self.log_prefix,
1802                    outpoint,
1803                    record.nodes.len(),
1804                    record.pending.len(),
1805                    record.calls,
1806                    record.passes
1807                );
1808                Walk {
1809                    seen: record
1810                        .nodes
1811                        .iter()
1812                        .filter_map(|w| Self::node_id(&w.node))
1813                        .collect(),
1814                    nodes_at_start: record.nodes.len(),
1815                    record,
1816                    calls_this_pass: 0,
1817                    bytes_this_pass: 0,
1818                    nodes_this_pass: 0,
1819                    resumed: true,
1820                    cap,
1821                    counted: false,
1822                    gone_on: false,
1823                    half: None,
1824                }
1825            }
1826        };
1827        let resumed = walk.resumed;
1828        let root_unproven = walk
1829            .record
1830            .nodes
1831            .first()
1832            .is_some_and(|w| w.node.proof.is_none());
1833        *self.walk.borrow_mut() = Some(walk);
1834
1835        let ended = async {
1836            if resumed && root_unproven {
1837                // The root is asked again (one call): once its block lands
1838                // the peer serves it PROVEN, and the walk from it is shorter
1839                // than the unproven ancestry the record is still walking.
1840                let root = PendingInput {
1841                    outpoint: outpoint.to_string(),
1842                    graph_id: outpoint.to_string(),
1843                    metadata: true,
1844                    spent_by: None,
1845                    parent_proven: false,
1846                };
1847                match race_or_deadline(self.fetch_root(&root), deadline.as_mut()).await {
1848                    None => return Ok(WalkEnd::Deferred("time")),
1849                    Some(Ok(node)) if node.proof.is_some() => {
1850                        // The walk is fresh, the PASS is not: what it spent
1851                        // of every counted limb goes with it (the re-ask
1852                        // appends nothing, so the nodes are 0 today; carried
1853                        // all the same, so a limb is never forgotten here).
1854                        let (carried, carried_bytes, carried_nodes) =
1855                            self.walk.borrow().as_ref().map_or((0, 0, 0), |w| {
1856                                (w.calls_this_pass, w.bytes_this_pass, w.nodes_this_pass)
1857                            });
1858                        self.drop_record(outpoint, DropReason::RootProven).await;
1859                        let mut restarted = Self::fresh_walk(score, outpoint, cap);
1860                        restarted.calls_this_pass = carried;
1861                        restarted.bytes_this_pass = carried_bytes;
1862                        restarted.nodes_this_pass = carried_nodes;
1863                        restarted.record.calls = u64::from(carried);
1864                        *self.walk.borrow_mut() = Some(restarted);
1865                    }
1866                    // Unchanged, or not answered: the record goes on.
1867                    Some(_) => {}
1868                }
1869            }
1870            self.append_walked().await?;
1871            self.walk_graph(Some(&mut *deadline)).await
1872        }
1873        .await;
1874        self.finish_pass(outpoint, ended).await
1875    }
1876
1877    /// Append the walk's fetched nodes to the graph (a resume, or a walk
1878    /// that goes on after its graph was discarded).
1879    async fn append_walked(&self) -> Result<(), GASPError> {
1880        let nodes: Vec<WalkedNode> = self
1881            .walk
1882            .borrow()
1883            .as_ref()
1884            .map(|w| w.record.nodes.clone())
1885            .unwrap_or_default();
1886        for walked in &nodes {
1887            self.storage
1888                .append_to_graph(&walked.node, walked.spent_by.as_deref())
1889                .await?;
1890        }
1891        Ok(())
1892    }
1893
1894    /// The end of a budgeted pass: complete the graph, defer it, or fail
1895    /// its UTXO. A walk past its per-graph budget that cannot be KEPT (past
1896    /// [`DEFERRED_GRAPHS_PER_PEER_TOPIC`] or the storage's ceiling) goes on under the per-peer budget alone, as
1897    /// every walk did before #555 (the lens fold's L4: such a graph completed
1898    /// in one pass before #555, and was then failed on every pass).
1899    async fn finish_pass(
1900        &self,
1901        outpoint: &str,
1902        mut ended: Result<WalkEnd, GASPError>,
1903    ) -> Result<Ingested, GASPError> {
1904        loop {
1905            let graph_id = Self::graph_id_of(self.walk.borrow().as_ref(), outpoint);
1906            // Whether a record of this graph is still held: not after a
1907            // `root_proven` restart, whose walk is fresh.
1908            let resumed = self.walk.borrow().as_ref().is_some_and(|w| w.resumed);
1909            let (reason, completed) = match ended {
1910                Ok(WalkEnd::Done) => match self.complete_graph(&graph_id).await {
1911                    Ok(true) => return Ok(Ingested::Completed),
1912                    Ok(false) => {
1913                        // Refused by the anchor check: a verdict, the cursor
1914                        // moves, nothing to resume.
1915                        self.walk.borrow_mut().take();
1916                        if resumed {
1917                            self.drop_record(outpoint, DropReason::Refused).await;
1918                        }
1919                        return Ok(Ingested::Completed);
1920                    }
1921                    // `complete_graph` discarded the graph already.
1922                    Err(GASPError::AnchorUnavailable(_)) => ("anchor_unavailable", true),
1923                    // The same hold, for a walk past the budget (E592 fold).
1924                    Err(GASPError::AnchorWalkHeld(_)) => ("anchor_walk_held", true),
1925                    // A finalize fault: the graph is still in hand.
1926                    Err(_) => {
1927                        let _ = self.storage.discard_graph(&graph_id).await;
1928                        ("fault", true)
1929                    }
1930                },
1931                Ok(WalkEnd::Deferred(reason)) => (reason, false),
1932                Err(e @ GASPError::NodeNotFound(_)) => {
1933                    let _ = self.storage.discard_graph(&graph_id).await;
1934                    self.walk.borrow_mut().take();
1935                    if resumed {
1936                        self.drop_record(outpoint, DropReason::NotHeld).await;
1937                    }
1938                    return Err(e);
1939                }
1940                // A walk ERROR on a FRESH walk keeps no record (the delta
1941                // fold's D-M1): a fault is not a budget cut, and before #555
1942                // it failed the UTXO. A record then costs a peer a budget
1943                // cut (`calls`, `time`, `peer_deadline`), never a quick 5xx:
1944                // a stranger who answered each input with a 503 kept a
1945                // one-node record per UTXO, 16 per (peer, topic) in one tick
1946                // with no time spent, and filled the worker's ceiling. A
1947                // RESUMED walk keeps its record (its progress was paid for).
1948                //
1949                // Unless it PAID (the delta-2 fold's D2-L1): a fresh walk
1950                // that faults after half its per-graph calls or half its
1951                // time keeps its record as a budget cut would, so an honest
1952                // flaky peer's deep graph is not walked from its root on
1953                // every tick ((1-p)^100 a clean pass at a fault rate p).
1954                Err(e) if !resumed && !self.walk_paid().await => {
1955                    warn!(
1956                        "{} Walk of {} faulted: no record, the UTXO fails as before #555: {}",
1957                        self.log_prefix, outpoint, e
1958                    );
1959                    let _ = self.storage.discard_graph(&graph_id).await;
1960                    self.walk.borrow_mut().take();
1961                    return Err(e);
1962                }
1963                Err(e) => {
1964                    // D2-M2: a RESUMED pass that faulted having appended
1965                    // nothing is idle; at DEFERRED_GRAPH_MAX_IDLE_FAULTS in a
1966                    // row the record goes and the UTXO fails.
1967                    let idle = {
1968                        let mut guard = self.walk.borrow_mut();
1969                        guard.as_mut().map_or(0, |w| {
1970                            if w.resumed && w.record.nodes.len() <= w.nodes_at_start {
1971                                w.record.idle_faults += 1;
1972                            }
1973                            w.record.idle_faults
1974                        })
1975                    };
1976                    if idle >= DEFERRED_GRAPH_MAX_IDLE_FAULTS {
1977                        warn!(
1978                            "{} Walk of {} faulted with nothing appended {} passes in a row: dropped: {}",
1979                            self.log_prefix, outpoint, idle, e
1980                        );
1981                        let _ = self.storage.discard_graph(&graph_id).await;
1982                        self.walk.borrow_mut().take();
1983                        self.drop_record(outpoint, DropReason::IdleFaults).await;
1984                        return Err(e);
1985                    }
1986                    warn!(
1987                        "{} Walk of {} faulted, deferred with its progress: {}",
1988                        self.log_prefix, outpoint, e
1989                    );
1990                    let _ = self.storage.discard_graph(&graph_id).await;
1991                    ("fault", false)
1992                }
1993            };
1994            // Only a walk the per-graph budget cut may go on (L4).
1995            let budget_cut = matches!(reason, "calls" | "time" | "bytes" | "nodes");
1996            let (why, mut walk) = match self.save_walk(reason, completed).await {
1997                Saved::Yes => {
1998                    let _ = self.storage.discard_graph(&graph_id).await;
1999                    return Ok(Ingested::Deferred);
2000                }
2001                Saved::WentOn => {
2002                    let _ = self.storage.discard_graph(&graph_id).await;
2003                    return Err(GASPError::Other(format!(
2004                        "graph {outpoint} went on and did not complete (bsv-low #555)"
2005                    )));
2006                }
2007                // A store FAULT at a deferral goes on as the other bounds do
2008                // (the delta fold's D-L2; counted `store_fault` by
2009                // `save_walk`): it failed the UTXO, a transient D1 fault
2010                // failing a graph the per-peer budget would have completed.
2011                Saved::Fault(e, walk) => {
2012                    if !budget_cut {
2013                        let _ = self.storage.discard_graph(&graph_id).await;
2014                        return Err(e);
2015                    }
2016                    (DropReason::StoreFault, walk)
2017                }
2018                // No room for a RESUMED walk (bsv-low #585): its held record
2019                // stays, so what this pass adds is lost and nothing else. A
2020                // budget cut goes on as the other bounds do; any other end
2021                // fails the UTXO and the next pass resumes from the record.
2022                Saved::Ceiling(walk) => {
2023                    if !budget_cut {
2024                        let _ = self.storage.discard_graph(&graph_id).await;
2025                        return Err(GASPError::Other(format!(
2026                            "deferred graph {outpoint} found no room, its record kept (bsv-low #585)"
2027                        )));
2028                    }
2029                    (DropReason::TooMany, walk)
2030                }
2031                Saved::Unkept(why, mut walk) => {
2032                    self.unkept(why, &mut walk).await;
2033                    if !(budget_cut && why == DropReason::TooMany) {
2034                        let _ = self.storage.discard_graph(&graph_id).await;
2035                        return Err(GASPError::Other(format!(
2036                            "deferred graph {outpoint} not kept: {} (bsv-low #555)",
2037                            why.as_str()
2038                        )));
2039                    }
2040                    (why, walk)
2041                }
2042            };
2043            info!(
2044                "{} Graph {} cannot be kept ({}): its walk goes on under the per-peer budget alone (bsv-low #555)",
2045                self.log_prefix,
2046                outpoint,
2047                why.as_str()
2048            );
2049            walk.cap = PassCap::NONE;
2050            walk.gone_on = true;
2051            *self.walk.borrow_mut() = Some(*walk);
2052            // The graph in hand was never discarded: walk on.
2053            ended = self.walk_graph(None).await;
2054        }
2055    }
2056
2057    /// After a COMPLETED graph's hook (bsv-low #555): a graph that landed
2058    /// deletes its record (a resumed one converged); one whose finalize did
2059    /// not land keeps its walk as a record with nothing pending, so the next
2060    /// pass completes it again without walking it again. No-op without a
2061    /// per-graph budget.
2062    async fn settle_completed(&self, landed: bool) {
2063        if self.graph_budget.is_none() {
2064            self.walk.borrow_mut().take();
2065            return;
2066        }
2067        if landed {
2068            let Some(walk) = self.walk.borrow_mut().take() else {
2069                return;
2070            };
2071            if walk.resumed {
2072                if let Err(e) = self
2073                    .storage
2074                    .delete_deferred_graph(&walk.record.outpoint)
2075                    .await
2076                {
2077                    warn!(
2078                        "{} Could not delete the record of converged graph {}: {}",
2079                        self.log_prefix, walk.record.outpoint, e
2080                    );
2081                }
2082                self.held_records
2083                    .set(self.held_records.get().saturating_sub(1));
2084                self.stats.borrow_mut().converged += 1;
2085                info!(
2086                    "{} Deferred graph {} CONVERGED after {} passes, {} calls, {} nodes (bsv-low #555)",
2087                    self.log_prefix,
2088                    walk.record.outpoint,
2089                    walk.record.passes + 1,
2090                    walk.record.calls,
2091                    walk.record.nodes.len()
2092                );
2093            }
2094        } else if let Saved::Unkept(why, mut walk) = self.save_walk("not_landed", true).await {
2095            self.unkept(why, &mut walk).await;
2096        }
2097    }
2098
2099    /// Save the walk in hand as its graph's record (bsv-low #555), counting
2100    /// a deferral; or hand it back UNKEPT with the reason (past a bound, or
2101    /// holding no node), or answer the storage's fault with the walk (a walk
2102    /// the per-graph budget cut then goes on, D-L2; any other fails its
2103    /// UTXO). A walk that already went on is neither saved nor counted again
2104    /// (D-L1). Counts the pass progressed or stalled (the lens fold's H1),
2105    /// once per pass.
2106    async fn save_walk(&self, reason: &'static str, completed: bool) -> Saved {
2107        let Some(mut walk) = self.walk.borrow_mut().take() else {
2108            // Nothing in hand: nothing to save.
2109            return Saved::Yes;
2110        };
2111        if walk.gone_on {
2112            // Its drop was counted when it went on (D-L1).
2113            return Saved::WentOn;
2114        }
2115        if !walk.counted {
2116            walk.counted = true;
2117            let mut stats = self.stats.borrow_mut();
2118            if completed || walk.record.nodes.len() > walk.nodes_at_start {
2119                stats.progressed += 1;
2120            } else {
2121                stats.stalled += 1;
2122            }
2123        }
2124        walk.record.passes += 1;
2125        walk.record.reason = reason.to_string();
2126        if walk.record.nodes.len() > walk.nodes_at_start {
2127            walk.record.idle_faults = 0;
2128        }
2129        // H1: a walk that holds no node is never kept (nothing would be lost
2130        // by not keeping it, and an empty record took one of the 16 places
2131        // for up to 60 passes, L2): its UTXO fails as before #555.
2132        if walk.record.nodes.is_empty() {
2133            return Saved::Unkept(DropReason::NoProgress, Box::new(walk));
2134        }
2135        if !walk.resumed && self.held_records.get() >= DEFERRED_GRAPHS_PER_PEER_TOPIC {
2136            return Saved::Unkept(DropReason::TooMany, Box::new(walk));
2137        }
2138        // No bound on a record's size (bsv-low #585): the figure is logged
2139        // and is the storage's to budget.
2140        let bytes = walk.record.byte_size();
2141        let outpoint = walk.record.outpoint.clone();
2142        match self.storage.save_deferred_graph(&walk.record).await {
2143            Ok(DeferredGraphSave::Saved) => {
2144                if !walk.resumed {
2145                    self.held_records.set(self.held_records.get() + 1);
2146                }
2147                self.stats.borrow_mut().deferred += 1;
2148                info!(
2149                    "{} DEFERRED graph {} ({}): {} nodes, {} pending, {} calls ({} this pass), pass {}, {} bytes (bsv-low #555)",
2150                    self.log_prefix,
2151                    outpoint,
2152                    reason,
2153                    walk.record.nodes.len(),
2154                    walk.record.pending.len(),
2155                    walk.record.calls,
2156                    walk.calls_this_pass,
2157                    walk.record.passes,
2158                    bytes
2159                );
2160                Saved::Yes
2161            }
2162            // The storage's ceiling is a budget of room, not a verdict on
2163            // the graph (bsv-low #585): a RESUMED walk keeps the record it
2164            // holds (it was deleted, and the graph walked from its root at
2165            // the next pass); a fresh walk has none to keep.
2166            Ok(DeferredGraphSave::AtCeiling) if walk.resumed => {
2167                self.note_dropped(&outpoint, DropReason::TooMany);
2168                Saved::Ceiling(Box::new(walk))
2169            }
2170            Ok(DeferredGraphSave::AtCeiling) => Saved::Unkept(DropReason::TooMany, Box::new(walk)),
2171            // The save is lost and counted `store_fault`. A RESUMED walk's
2172            // held record is KEPT (the delta-2 lens's D2-N2: it was deleted,
2173            // and one transient D1 fault threw away every earlier pass of a
2174            // walk the per-peer budget did not then finish): the next pass
2175            // resumes from it, losing this pass only; a walk that goes on
2176            // and lands deletes it as a converged one.
2177            Err(e) => {
2178                self.note_dropped(&outpoint, DropReason::StoreFault);
2179                Saved::Fault(e, Box::new(walk))
2180            }
2181        }
2182    }
2183
2184    /// A walk [`Self::save_walk`] did not keep: its held record (if any) is
2185    /// deleted with the reason, else the reason is counted; a fresh walk
2186    /// that fetched nothing was never a record and is only logged.
2187    async fn unkept(&self, why: DropReason, walk: &mut Walk) {
2188        let outpoint = walk.record.outpoint.clone();
2189        if walk.resumed {
2190            self.drop_record(&outpoint, why).await;
2191            walk.resumed = false;
2192        } else if why == DropReason::NoProgress {
2193            warn!(
2194                "{} Walk of {} fetched nothing: not kept, the UTXO fails (bsv-low #555)",
2195                self.log_prefix, outpoint
2196            );
2197        } else {
2198            self.note_dropped(&outpoint, why);
2199        }
2200    }
2201
2202    /// Whether the walk in hand has PAID for a record (the delta-2 fold's
2203    /// D2-L1): half its per-graph calls made in this pass, or its half
2204    /// deadline fallen due. A walk that holds no node never has.
2205    async fn walk_paid(&self) -> bool {
2206        let Some(budget) = &self.graph_budget else {
2207            return false;
2208        };
2209        let mut half = {
2210            let mut guard = self.walk.borrow_mut();
2211            let Some(walk) = guard.as_mut() else {
2212                return false;
2213            };
2214            if walk.record.nodes.is_empty() {
2215                return false;
2216            }
2217            if u64::from(walk.calls_this_pass) * 2 >= u64::from(budget.max_calls) {
2218                return true;
2219            }
2220            match walk.half.take() {
2221                Some(half) => half,
2222                None => return false,
2223            }
2224        };
2225        let paid = Self::due(&mut half).await;
2226        if let Some(walk) = self.walk.borrow_mut().as_mut() {
2227            walk.half = Some(half);
2228        }
2229        paid
2230    }
2231
2232    /// Delete a held record and count it dropped.
2233    async fn drop_record(&self, outpoint: &str, reason: DropReason) {
2234        if let Err(e) = self.storage.delete_deferred_graph(outpoint).await {
2235            warn!(
2236                "{} Could not delete the record of deferred graph {}: {}",
2237                self.log_prefix, outpoint, e
2238            );
2239        }
2240        self.held_records
2241            .set(self.held_records.get().saturating_sub(1));
2242        self.note_dropped(outpoint, reason);
2243    }
2244
2245    fn note_dropped(&self, outpoint: &str, reason: DropReason) {
2246        warn!(
2247            "{} Deferred graph {} DROPPED ({}) (bsv-low #555)",
2248            self.log_prefix,
2249            outpoint,
2250            reason.as_str()
2251        );
2252        self.stats.borrow_mut().dropped.push(DroppedGraph {
2253            outpoint: outpoint.to_string(),
2254            reason,
2255        });
2256    }
2257
2258    /// Save the walk of the graph that was in hand when a deadline dropped
2259    /// `sync` (bsv-low #555): the per-peer deadline (D16) defers it like the
2260    /// per-graph one, so the next pass resumes it instead of walking it
2261    /// again. No-op without a per-graph budget or with no walk in hand.
2262    pub async fn defer_in_flight(&self) {
2263        if self.graph_budget.is_some() {
2264            if let Saved::Unkept(why, mut walk) = self.save_walk("peer_deadline", false).await {
2265                self.unkept(why, &mut walk).await;
2266            }
2267        }
2268    }
2269
2270    /// The graph id of the walk in hand: its root's, else the outpoint.
2271    fn graph_id_of(walk: Option<&Walk>, outpoint: &str) -> String {
2272        walk.and_then(|w| w.record.nodes.first())
2273            .map_or_else(|| outpoint.to_string(), |w| w.node.graph_id.clone())
2274    }
2275
2276    /// `txid.outputIndex` of a node, its txid computed from its raw bytes.
2277    fn node_id(node: &GASPNode) -> Option<String> {
2278        bsv_rs::transaction::Transaction::from_hex(&node.raw_tx)
2279            .ok()
2280            .map(|tx| format!("{}.{}", tx.id(), node.output_index))
2281    }
2282
2283    /// Count a node the graph in hand was SERVED (bsv-low #586): the bytes
2284    /// the peer sent for it, which are the bytes a record keeps of it.
2285    fn count_fetched(&self, node: &GASPNode) {
2286        let bytes = (node.raw_tx.len() + node.proof.as_ref().map_or(0, String::len)) as u64;
2287        self.bytes_fetched.set(self.bytes_fetched.get() + bytes);
2288        if let Some(w) = self.walk.borrow_mut().as_mut() {
2289            w.bytes_this_pass += bytes;
2290        }
2291    }
2292
2293    /// Count one call of the graph in hand.
2294    fn count_call(&self) {
2295        self.calls_made.set(self.calls_made.get() + 1);
2296        if let Some(w) = self.walk.borrow_mut().as_mut() {
2297            w.calls_this_pass += 1;
2298            w.record.calls += 1;
2299        }
2300    }
2301
2302    /// Push a local UTXO's graph to the remote.
2303    async fn push_utxo(&self, utxo: &GASPOutput) -> Result<(), GASPError> {
2304        let outpoint = format!("{}.{}", utxo.txid, utxo.output_index);
2305        debug!("{} Hydrating node for {}", self.log_prefix, outpoint);
2306
2307        let node = self
2308            .storage
2309            .hydrate_gasp_node(&outpoint, &utxo.txid, utxo.output_index, true)
2310            .await?;
2311
2312        self.process_outgoing_node(&node, &mut std::collections::HashSet::new())
2313            .await?;
2314
2315        Ok(())
2316    }
2317
2318    /// Walk the graph in hand until nothing is pending (an explicit stack,
2319    /// bsv-low #555; before it a recursion, in the same order: an input's
2320    /// whole branch before its next sibling). Each step's result is
2321    /// committed to the walk only once the step has finished, so a deadline
2322    /// that drops this future leaves the walk as it was before that step.
2323    /// With a per-graph budget the walk stops at a counted limb of it (its
2324    /// calls; the bytes it was served or the nodes it appended, bsv-low #586)
2325    /// or at its `deadline`, and answers `Deferred` with the limb's name;
2326    /// without one it runs to its end. A limb is read BEFORE a step, so a
2327    /// pass always makes one: no node is too big to walk.
2328    async fn walk_graph(
2329        &self,
2330        mut deadline: Option<&mut crate::engine::SleepFuture>,
2331    ) -> Result<WalkEnd, GASPError> {
2332        loop {
2333            let (item, over) = {
2334                let walk = self.walk.borrow();
2335                let Some(walk) = walk.as_ref() else {
2336                    return Ok(WalkEnd::Done);
2337                };
2338                (
2339                    walk.record.pending.last().cloned(),
2340                    self.graph_budget.as_ref().and_then(|_| walk.over()),
2341                )
2342            };
2343            let Some(item) = item else {
2344                return Ok(WalkEnd::Done);
2345            };
2346            // One failed round trip per decoy: an outpoint pruned earlier in
2347            // this sync is not asked for again, even when a later graph of
2348            // the sync names it (two UTXOs of one decoy-bearing transaction).
2349            if item.parent_proven && self.pruned.borrow().contains(&item.outpoint) {
2350                debug!(
2351                    "{} Input {} of {:?} was already pruned in this sync; not re-requested",
2352                    self.log_prefix, item.outpoint, item.spent_by
2353                );
2354                if let Some(w) = self.walk.borrow_mut().as_mut() {
2355                    w.record.pending.pop();
2356                }
2357                continue;
2358            }
2359            if let Some(limb) = over {
2360                return Ok(WalkEnd::Deferred(limb));
2361            }
2362            let step = self.step(&item);
2363            let out = match deadline.as_mut() {
2364                Some(deadline) => match race_or_deadline(step, deadline.as_mut()).await {
2365                    Some(out) => out,
2366                    None => return Ok(WalkEnd::Deferred("time")),
2367                },
2368                None => step.await,
2369            }?;
2370            self.commit(out);
2371        }
2372    }
2373
2374    /// Commit one finished step to the walk in hand: its item is no longer
2375    /// pending, and an appended node's needed inputs are, the first of them
2376    /// on top.
2377    fn commit(&self, out: StepOut) {
2378        let mut walk = self.walk.borrow_mut();
2379        let Some(walk) = walk.as_mut() else {
2380            return;
2381        };
2382        walk.record.pending.pop();
2383        if let StepOut::Appended {
2384            node_id,
2385            walked,
2386            children,
2387        } = out
2388        {
2389            walk.seen.insert(node_id);
2390            walk.nodes_this_pass += 1;
2391            self.nodes_appended.set(self.nodes_appended.get() + 1);
2392            walk.record.nodes.push(*walked);
2393            walk.record.pending.extend(children.into_iter().rev());
2394        }
2395    }
2396
2397    /// The walk from a root already in hand (the lib tests' entry, from
2398    /// before the walk was a stack): its proof hydrated, then every needed
2399    /// input to the end.
2400    #[cfg(test)]
2401    async fn process_incoming_node(
2402        &self,
2403        node: &GASPNode,
2404        _spent_by: Option<&str>,
2405        _seen: &mut std::collections::HashSet<String>,
2406    ) -> Result<(), GASPError> {
2407        let root = PendingInput {
2408            outpoint: node.graph_id.clone(),
2409            graph_id: node.graph_id.clone(),
2410            metadata: true,
2411            spent_by: None,
2412            parent_proven: false,
2413        };
2414        *self.walk.borrow_mut() = Some(Walk {
2415            record: DeferredGraph {
2416                peer: String::new(),
2417                topic: String::new(),
2418                outpoint: node.graph_id.clone(),
2419                score: 0,
2420                nodes: Vec::new(),
2421                pending: vec![root.clone()],
2422                calls: 0,
2423                passes: 0,
2424                reason: String::new(),
2425                configured: false,
2426                idle_faults: 0,
2427            },
2428            seen: std::collections::HashSet::new(),
2429            calls_this_pass: 0,
2430            bytes_this_pass: 0,
2431            nodes_this_pass: 0,
2432            resumed: false,
2433            cap: PassCap::NONE,
2434            nodes_at_start: 0,
2435            counted: false,
2436            gone_on: false,
2437            half: None,
2438        });
2439        let node = self.hydrate_root(node.clone()).await;
2440        let out = self.absorb(&root, node).await?;
2441        self.commit(out);
2442        self.walk_graph(None).await.map(|_| ())
2443    }
2444
2445    /// The ROOT of a graph: the peer's node for the UTXO, its own proof
2446    /// hydrated by the ancestor fetcher when it has none.
2447    async fn fetch_root(&self, item: &PendingInput) -> Result<GASPNode, GASPError> {
2448        let (txid, oi) = parse_outpoint(&item.outpoint)
2449            .ok_or_else(|| GASPError::Other(format!("bad outpoint {}", item.outpoint)))?;
2450        self.count_call();
2451        let node = self
2452            .remote
2453            .request_node(&item.graph_id, &txid, oi, item.metadata)
2454            .await?;
2455        self.count_fetched(&node);
2456        Ok(self.hydrate_root(node).await)
2457    }
2458
2459    /// The root's own proof, hydrated by the ancestor fetcher when the peer
2460    /// served it with none.
2461    async fn hydrate_root(&self, mut node: GASPNode) -> GASPNode {
2462        // GOD-TIER proof-anchoring (#126): if the peer served this node WITHOUT
2463        // a merkle proof but the tx is mined, hydrate its OWN proof via the
2464        // ancestor fetcher (WoC `/beef`). A proven node ends the walk UNLESS
2465        // its topic manager names inputs needed for overlay history. Managers
2466        // naming nothing still avoid the spent prior contract-state (a
2467        // 2-tx-pattern template, or a covenant's previous UTXO). Without this,
2468        // the walk reaches a spent input whose output
2469        // record exists in storage (so `find_needed_inputs` strips it) but whose tx
2470        // is absent from the in-memory graph, so the node's BEEF fails "Missing
2471        // source transaction" and the whole graph is discarded. Legacy beta's
2472        // GASP-serve omits proofs, so this is required cross-stack. No-op when no
2473        // fetcher is configured (production default unchanged) or the node already
2474        // carries a proof (children fetched via the walk already do).
2475        // Only the GRAPH ROOT arrives from the peer (spent_by == None); children
2476        // come from the ancestry walk already carrying their proofs, so gate on
2477        // the root to avoid redundant re-fetches.
2478        if node.proof.is_none() {
2479            if let Some(fetcher) = &self.ancestor_fetcher {
2480                if let Some(node_txid) =
2481                    Self::node_id(&node).and_then(|id| parse_outpoint(&id).map(|(txid, _)| txid))
2482                {
2483                    self.count_call();
2484                    if let Ok(root_fetch) = fetcher.fetch_ancestor(&node_txid).await {
2485                        if root_fetch.proof.is_some() {
2486                            node.proof = root_fetch.proof;
2487                        }
2488                    }
2489                }
2490            }
2491        }
2492        node
2493    }
2494
2495    /// One step of the walk: fetch `item`, append it to the graph, name its
2496    /// needed inputs. Nothing of the walk is changed here (see
2497    /// [`Self::walk_graph`]).
2498    async fn step(&self, item: &PendingInput) -> Result<StepOut, GASPError> {
2499        // THE D8 DECOY RULE (zanaadu-v2 #314, the owner's ruling of
2500        // 2026-10-06). A DELIBERATE DIVERGENCE from the reference.
2501        //
2502        // The reference requests every needed input with no catch
2503        // (`GASP.ts:602`, `await this.remote.requestNode(...)` inside
2504        // `processIncomingNode`), so one input the peer cannot serve
2505        // throws out of the walk and the per-UTXO catch in `sync`
2506        // drops that whole UTXO: the graph is never completed.
2507        //
2508        // We keep that for an UNPROVEN parent and diverge for a PROVEN
2509        // one. A proven parent needs no input for SPV; the only
2510        // inputs `find_needed_inputs` returns for it are the ones its
2511        // topic manager NAMED as history. A manager cannot always
2512        // tell which input is the real one: a head covenant that
2513        // signs under ANYONECANPAY lets a spender place a decoy
2514        // witness-shaped input ahead of the real head input, and once
2515        // mined it is permanent. The pairing is that such a manager
2516        // names EVERY witness-shaped input, and the engine PRUNES the
2517        // branch of a named input the PEER DEFINITELY DOES NOT HOLD
2518        // instead of discarding the graph: warn, count, carry on with
2519        // the parent's other named inputs, complete the graph with
2520        // what it has. Under the reference's rule one decoy would
2521        // strand the chain behind it on every sync, forever.
2522        //
2523        // An unproven parent's inputs are SPV necessities, not named
2524        // history: a missing one still fails the UTXO (the `?` below),
2525        // exactly as the reference does.
2526        //
2527        // THE CLASSIFICATION RULE. A named input is pruned only on a
2528        // DEFINITE answer: the peer said it does not hold the
2529        // outpoint, the typed class `GASPError::NodeNotFound`. Every
2530        // other error (the request could not be sent, a timeout, a
2531        // 5xx, a 429, a body that does not parse) is a fault of the
2532        // moment and says nothing about what the peer holds: it
2533        // fails the UTXO as the reference does, the per-UTXO arm of
2534        // `sync` records it in the cursor gap guard, and the next
2535        // sync asks again. Pruning on such a fault would cut the
2536        // REAL head input, finalize a truncated graph, advance the
2537        // cursor and never ask for that history again. The remote
2538        // decides the class: the worker's maps HTTP 400 from
2539        // `/requestForeignGASPNode` to `NodeNotFound` and nothing
2540        // else (`gasp_remote.rs`), because 400 is what the reference
2541        // peer answers for an outpoint it does not hold. Accepted
2542        // residual: the reference answers the same masked 400 when
2543        // its own storage faults inside `provideForeignGASPNode`, so
2544        // that fault at a reference peer reads as "not held".
2545        //
2546        // THE FETCHER ARM NEVER PRUNES. A chain fetcher has no
2547        // definite "cannot serve": every input of a mined transaction
2548        // exists on chain, so each of its errors is a fault of the
2549        // moment (its per-tick budget, a provider outage, a rate
2550        // limit), whatever class it carries. With a real chain
2551        // fetcher a decoy is SERVED from chain (one fetch, no failed
2552        // round trip, no prune), so the prune lives on the peer arm,
2553        // which is what runs when no fetcher is installed.
2554        //
2555        // NOTHING HERE BOUNDS THE DECOYS OF ONE PARENT. The manager's
2556        // list has no cap, and each definite decoy is one sequential
2557        // failed round trip inside the per-peer sync budget. A parent
2558        // with enough of them exceeds that budget and the sync is
2559        // dropped whole; what a dropped sync keeps is bsv-low #552's
2560        // ground, and a cap on the list is the manager's choice.
2561        let node = if item.spent_by.is_none() {
2562            self.fetch_root(item).await?
2563        } else {
2564            let Some((txid, oi)) = parse_outpoint(&item.outpoint) else {
2565                return Ok(StepOut::Pruned);
2566            };
2567            self.count_call();
2568            match &self.ancestor_fetcher {
2569                // OPT-IN ancestry hydration (off by default). When a
2570                // fetcher is configured we KNOW the peer cannot serve
2571                // ancestry — e.g. legacy beta stores minimal BEEFs and
2572                // returns HTTP 400 "Incomplete SPV data!" for every
2573                // ancestor — so we SKIP the doomed peer round-trip
2574                // entirely and fetch the ancestor's raw tx from chain
2575                // directly. Asking the peer first would cost one
2576                // sequential request per ancestor (the ~90-deep
2577                // user_registry chain → ~90 round-trips), enough to
2578                // exhaust a worker invocation before the graph finalizes.
2579                //
2580                // The fetcher returns the ancestor's rawtx and, when it
2581                // is mined, its BUMP proof. A proven node ends the walk
2582                // UNLESS its topic manager names inputs. Empty managers
2583                // still stop at the first proven layer; managers needing
2584                // history select their ancestors through this same
2585                // fetcher. Without a proof, every input is requested
2586                // as before.
2587                // The transaction DAG (no cycles), `seen` keyed by
2588                // `txid.outputIndex`, the already-known strip, and the
2589                // manager naming nothing at genesis bound the walk.
2590                // There is no node cap, matching the reference's
2591                // undefined `maxNodesInGraph` in `Engine.startGASPSync`.
2592                // A per-peer budget drops the sync future at its
2593                // deadline: graphs finalized before it stay admitted
2594                // (bsv-low #552), the graph in flight is lost whole,
2595                // and ONE graph whose own walk outlasts the budget
2596                // never completes unless a per-graph budget defers and
2597                // resumes it (bsv-low #555).
2598                //
2599                // Every fetcher error fails the UTXO (the `?`):
2600                // the fetcher arm never prunes, see the rule above.
2601                Some(fetcher) => {
2602                    let ancestor = fetcher.fetch_ancestor(&txid).await?;
2603                    let node = GASPNode {
2604                        graph_id: item.graph_id.clone(),
2605                        raw_tx: ancestor.raw_tx,
2606                        output_index: oi,
2607                        proof: ancestor.proof,
2608                        tx_metadata: None,
2609                        output_metadata: None,
2610                        inputs: None,
2611                    };
2612                    self.count_fetched(&node);
2613                    node
2614                }
2615                // Default / production: no fetcher → ask the peer.
2616                None => match self
2617                    .remote
2618                    .request_node(&item.graph_id, &txid, oi, item.metadata)
2619                    .await
2620                {
2621                    Ok(child_node) => {
2622                        self.count_fetched(&child_node);
2623                        child_node
2624                    }
2625                    // Proven parent and a DEFINITE "not held": the D8 prune
2626                    // (see the rule above).
2627                    Err(e @ GASPError::NodeNotFound(_)) if item.parent_proven => {
2628                        warn!(
2629                            "{} Pruned input {} named by proven node {}: the peer does not hold it: {}",
2630                            self.log_prefix,
2631                            item.outpoint,
2632                            item.spent_by.as_deref().unwrap_or_default(),
2633                            e
2634                        );
2635                        if self.pruned.borrow_mut().insert(item.outpoint.clone()) {
2636                            self.pruned_inputs.set(self.pruned_inputs.get() + 1);
2637                        }
2638                        return Ok(StepOut::Pruned);
2639                    }
2640                    // Any other error, and every error under an unproven
2641                    // parent: propagate, as the reference does.
2642                    Err(e) => return Err(e),
2643                },
2644            }
2645        };
2646        self.absorb(item, node).await
2647    }
2648
2649    /// The node of `item`, fetched: append it to the graph and name its
2650    /// needed inputs.
2651    async fn absorb(&self, item: &PendingInput, node: GASPNode) -> Result<StepOut, GASPError> {
2652        // Key by the node's own TXID (computed from raw_tx), NOT graph_id (which
2653        // is constant across a whole graph) and NOT the raw_tx hex. Mirrors TS
2654        // @bsv/gasp processIncomingNode: nodeId = `${computeTXID(rawTx)}.${oi}`
2655        // (GASP.js:319) and spentBy = compute36ByteStructure(computeTXID(rawTx), oi)
2656        // (GASP.js:335). Matches the append-side key in gasp_overlay.rs
2657        // (Transaction::from_hex(raw_tx).id()). Using graph_id/raw_tx here orphaned
2658        // every child node → multi-node graphs never assembled → stranded sync.
2659        let node_txid = match bsv_rs::transaction::Transaction::from_hex(&node.raw_tx) {
2660            Ok(tx) => tx.id(),
2661            Err(_) => node.raw_tx[..node.raw_tx.len().min(64)].to_string(),
2662        };
2663        let node_id = format!("{}.{}", node_txid, node.output_index);
2664        if self
2665            .walk
2666            .borrow()
2667            .as_ref()
2668            .is_some_and(|w| w.seen.contains(&node_id))
2669        {
2670            return Ok(StepOut::Seen);
2671        }
2672
2673        self.storage
2674            .append_to_graph(&node, item.spent_by.as_deref())
2675            .await?;
2676
2677        let mut children = Vec::new();
2678        if let Some(needed) = self.storage.find_needed_inputs(&node).await? {
2679            let parent_proven = node.proof.is_some();
2680            for (outpoint, input_req) in &needed.requested_inputs {
2681                if parse_outpoint(outpoint).is_some() {
2682                    children.push(PendingInput {
2683                        outpoint: outpoint.clone(),
2684                        graph_id: node.graph_id.clone(),
2685                        metadata: input_req.metadata,
2686                        spent_by: Some(node_id.clone()),
2687                        parent_proven,
2688                    });
2689                }
2690            }
2691        }
2692        Ok(StepOut::Appended {
2693            node_id,
2694            walked: Box::new(WalkedNode {
2695                node,
2696                spent_by: item.spent_by.clone(),
2697            }),
2698            children,
2699        })
2700    }
2701
2702    /// Process an outgoing node: submit to remote, then recursively send requested inputs.
2703    fn process_outgoing_node<'b>(
2704        &'b self,
2705        node: &'b GASPNode,
2706        seen: &'b mut std::collections::HashSet<String>,
2707    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), GASPError>> + 'b>> {
2708        Box::pin(async move {
2709            if self.unidirectional {
2710                return Ok(());
2711            }
2712
2713            let node_id = format!("{}.{}", node.graph_id, node.output_index);
2714            if seen.contains(&node_id) {
2715                return Ok(());
2716            }
2717            seen.insert(node_id);
2718
2719            if let Some(response) = self.remote.submit_node(node).await? {
2720                for (outpoint, input_req) in &response.requested_inputs {
2721                    if let Some((txid, oi)) = parse_outpoint(outpoint) {
2722                        match self
2723                            .storage
2724                            .hydrate_gasp_node(&node.graph_id, &txid, oi, input_req.metadata)
2725                            .await
2726                        {
2727                            Ok(hydrated) => {
2728                                self.process_outgoing_node(&hydrated, seen).await?;
2729                            }
2730                            Err(e) => {
2731                                error!("{} Error hydrating outgoing node: {}", self.log_prefix, e);
2732                                return Ok(()); // Stop this branch, remote will discard
2733                            }
2734                        }
2735                    }
2736                }
2737            }
2738
2739            Ok(())
2740        })
2741    }
2742
2743    /// Validate and finalize a completed graph, or discard on failure.
2744    /// `Ok(true)`: finalized; `Ok(false)`: refused by the anchor check and
2745    /// discarded (a verdict: the cursor moves past it).
2746    async fn complete_graph(&self, graph_id: &str) -> Result<bool, GASPError> {
2747        info!("{} Completing graph: {}", self.log_prefix, graph_id);
2748        match self.storage.validate_graph_anchor(graph_id).await {
2749            Ok(()) => {
2750                self.storage.finalize_graph(graph_id).await?;
2751                info!("{} Graph finalized: {}", self.log_prefix, graph_id);
2752                Ok(true)
2753            }
2754            Err(e) => {
2755                warn!(
2756                    "{} Graph validation failed for {}: {}. Discarding.",
2757                    self.log_prefix, graph_id, e
2758                );
2759                self.storage.discard_graph(graph_id).await?;
2760                // The anchor could not be checked (a tracker outage, a
2761                // storage fault, a topic manager that answered the replay
2762                // with an error): no verdict. Fail the UTXO so the gap guard
2763                // re-requests it, instead of advancing the cursor past a
2764                // graph nobody judged (a divergence by addition, bsv-low
2765                // #551: the reference discards and moves on either way).
2766                if matches!(e, GASPError::AnchorUnavailable(_)) {
2767                    return Err(e);
2768                }
2769                // A walk past the budget: held the same way, counted apart
2770                // (the E592 lens fold, H1 and L3).
2771                if matches!(e, GASPError::AnchorWalkHeld(_)) {
2772                    self.anchor_walk_held.set(self.anchor_walk_held.get() + 1);
2773                    return Err(e);
2774                }
2775                self.discarded_graphs.set(self.discarded_graphs.get() + 1);
2776                Ok(false)
2777            }
2778        }
2779    }
2780}
2781
2782/// Parse "txid.outputIndex" into components.
2783pub fn parse_outpoint(s: &str) -> Option<(String, u32)> {
2784    let parts: Vec<&str> = s.splitn(2, '.').collect();
2785    if parts.len() != 2 {
2786        return None;
2787    }
2788    let oi = parts[1].parse::<u32>().ok()?;
2789    Some((parts[0].to_string(), oi))
2790}
2791
2792// ============================================================================
2793// Error type
2794// ============================================================================
2795
2796/// GASP protocol errors.
2797#[derive(Debug, thiserror::Error)]
2798pub enum GASPError {
2799    /// Version mismatch between peers.
2800    #[error("version mismatch: local={local}, remote={remote}")]
2801    VersionMismatch { local: u32, remote: u32 },
2802
2803    /// Invalid timestamp format.
2804    #[error("invalid timestamp: {0}")]
2805    InvalidTimestamp(String),
2806
2807    /// Node not found.
2808    #[error("node not found: {0}")]
2809    NodeNotFound(String),
2810
2811    /// Graph validation failed.
2812    #[error("graph validation failed: {0}")]
2813    ValidationFailed(String),
2814
2815    /// Network/communication error.
2816    #[error("remote error: {0}")]
2817    RemoteError(String),
2818
2819    /// Storage error.
2820    #[error("storage error: {0}")]
2821    StorageError(String),
2822
2823    /// A graph's anchor could not be CHECKED right now (the chain tracker or
2824    /// a storage read faulted, or the topic manager answered the replay with
2825    /// an error): a fault of the moment, not a verdict on the graph. `GASPSync::complete_graph` discards the graph and fails the
2826    /// UTXO, so the cursor gap guard asks for it again (bsv-low #551).
2827    #[error("anchor unavailable: {0}")]
2828    AnchorUnavailable(String),
2829
2830    /// The anchor's script walk could not run within the engine's walk
2831    /// budget (bsv-low #592; the E592 lens fold, H1): no verdict, and
2832    /// nothing stands in for one (no network accepted these bytes). Held
2833    /// exactly as [`GASPError::AnchorUnavailable`] holds: the graph is not
2834    /// finalized, the UTXO fails and the cursor stays below it, until the
2835    /// peer serves its root PROVEN (#555's `root_proven` restart, whose walk
2836    /// trusts the proof and runs no script). Counted apart
2837    /// (`GASPSync::anchor_walk_held`).
2838    #[error("anchor walk held: {0}")]
2839    AnchorWalkHeld(String),
2840
2841    /// Generic error.
2842    #[error("{0}")]
2843    Other(String),
2844}
2845
2846// ============================================================================
2847// Tests
2848// ============================================================================
2849
2850#[cfg(test)]
2851mod tests {
2852    use super::*;
2853    use std::collections::HashMap;
2854    use std::sync::Mutex;
2855
2856    // ── race_or_deadline (bsv-low#257/#302, generalized here) ──────────
2857
2858    #[tokio::test]
2859    async fn race_or_deadline_future_wins() {
2860        let out = race_or_deadline(async { 42u32 }, std::future::pending::<()>()).await;
2861        assert_eq!(out, Some(42));
2862    }
2863
2864    #[tokio::test]
2865    async fn race_or_deadline_deadline_wins() {
2866        let out = race_or_deadline(std::future::pending::<u32>(), async {}).await;
2867        assert_eq!(out, None);
2868    }
2869
2870    // The lens fold's HIGH-1: a deadline that falls due inside a write
2871    // section waits for the section to close, then drops the future at its
2872    // next pending point; the writer that asks at the boundary stops there.
2873    #[tokio::test]
2874    async fn guarded_race_never_drops_inside_a_write_section() {
2875        let gate = SubmitGate::default();
2876        let written = std::cell::Cell::new(0u32);
2877        let out = race_or_deadline_guarded(
2878            async {
2879                for _ in 0..3 {
2880                    gate.stop_if_due().await;
2881                    let _section = gate.write_section();
2882                    // Two writes of one transaction, each a round trip.
2883                    tokio::task::yield_now().await;
2884                    written.set(written.get() + 1);
2885                    tokio::task::yield_now().await;
2886                    written.set(written.get() + 1);
2887                }
2888            },
2889            async {},
2890            &gate,
2891        )
2892        .await;
2893        assert_eq!(out, None, "the deadline still wins");
2894        assert_eq!(written.get(), 2, "one transaction, whole, and no second");
2895        assert!(
2896            !gate.deadline_is_due(),
2897            "the flag does not outlive the race"
2898        );
2899
2900        // With no section open it is `race_or_deadline`.
2901        let out = race_or_deadline_guarded(std::future::pending::<u8>(), async {}, &gate).await;
2902        assert_eq!(out, None);
2903        let out = race_or_deadline_guarded(async { 7u8 }, std::future::pending(), &gate).await;
2904        assert_eq!(out, Some(7));
2905        // A future that finishes inside its section after the deadline wins.
2906        let out = race_or_deadline_guarded(
2907            async {
2908                let _section = gate.write_section();
2909                tokio::task::yield_now().await;
2910                7u8
2911            },
2912            async {},
2913            &gate,
2914        )
2915        .await;
2916        assert_eq!(out, Some(7));
2917        assert!(!gate.deadline_is_due());
2918    }
2919
2920    #[tokio::test]
2921    async fn race_or_deadline_ready_future_beats_ready_deadline() {
2922        // The sync future is polled FIRST — an instantly-ready result wins
2923        // even against an already-expired deadline (a completed sync is
2924        // never discarded).
2925        let out = race_or_deadline(async { 7u32 }, async {}).await;
2926        assert_eq!(out, Some(7));
2927    }
2928
2929    // ── peer_sync_quarantined (bsv-low#302 pure rule) ──────────────────
2930
2931    fn health(fails: u64, age: Option<u64>) -> crate::storage::PeerSyncHealth {
2932        crate::storage::PeerSyncHealth {
2933            consecutive_failures: fails,
2934            secs_since_last_attempt: age,
2935        }
2936    }
2937
2938    #[test]
2939    fn quarantine_needs_the_full_threshold() {
2940        assert!(!peer_sync_quarantined(&health(0, Some(0))));
2941        assert!(!peer_sync_quarantined(&health(
2942            PEER_QUARANTINE_THRESHOLD - 1,
2943            Some(0)
2944        )));
2945        assert!(peer_sync_quarantined(&health(
2946            PEER_QUARANTINE_THRESHOLD,
2947            Some(0)
2948        )));
2949        assert!(peer_sync_quarantined(&health(
2950            PEER_QUARANTINE_THRESHOLD + 100,
2951            Some(0)
2952        )));
2953    }
2954
2955    #[test]
2956    fn quarantine_reopens_one_probe_after_the_reprobe_window() {
2957        let fails = PEER_QUARANTINE_THRESHOLD;
2958        assert!(peer_sync_quarantined(&health(
2959            fails,
2960            Some(PEER_QUARANTINE_REPROBE_SECS - 1)
2961        )));
2962        // AT the window boundary the probe re-opens — a quarantined peer is
2963        // never stranded forever.
2964        assert!(!peer_sync_quarantined(&health(
2965            fails,
2966            Some(PEER_QUARANTINE_REPROBE_SECS)
2967        )));
2968    }
2969
2970    #[test]
2971    fn never_attempted_peer_is_never_quarantined() {
2972        // `None` age = pristine peer OR a backend without health tracking —
2973        // fail-safe: always attempted.
2974        assert!(!peer_sync_quarantined(&health(u64::MAX, None)));
2975    }
2976
2977    // ── Mock GASPStorage ───────────────────────────────────────────────
2978
2979    struct MockGASPStorage {
2980        utxos: Vec<GASPOutput>,
2981        graphs: Mutex<HashMap<String, Vec<GASPNode>>>,
2982        finalized: Mutex<Vec<String>>,
2983        discarded: Mutex<Vec<String>>,
2984    }
2985
2986    impl MockGASPStorage {
2987        fn new(utxos: Vec<GASPOutput>) -> Self {
2988            Self {
2989                utxos,
2990                graphs: Mutex::new(HashMap::new()),
2991                finalized: Mutex::new(Vec::new()),
2992                discarded: Mutex::new(Vec::new()),
2993            }
2994        }
2995    }
2996
2997    #[async_trait(?Send)]
2998    impl GASPStorage for MockGASPStorage {
2999        // Keeps no deferred graphs (bsv-low #555): never given a per-graph budget.
3000        async fn load_deferred_graphs(
3001            &self,
3002        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
3003            Ok(Vec::new())
3004        }
3005        async fn get_deferred_graph(
3006            &self,
3007            _: &str,
3008        ) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
3009            Ok(None)
3010        }
3011        async fn save_deferred_graph(
3012            &self,
3013            _: &crate::gasp::DeferredGraph,
3014        ) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
3015            Err(GASPError::StorageError("keeps no deferred graphs".into()))
3016        }
3017        async fn delete_deferred_graph(&self, _: &str) -> Result<(), GASPError> {
3018            Ok(())
3019        }
3020        async fn find_known_utxos(
3021            &self,
3022            since: u64,
3023            _limit: Option<u64>,
3024        ) -> Result<Vec<GASPOutput>, GASPError> {
3025            Ok(self
3026                .utxos
3027                .iter()
3028                .filter(|u| u.score as u64 >= since)
3029                .cloned()
3030                .collect())
3031        }
3032
3033        async fn hydrate_gasp_node(
3034            &self,
3035            graph_id: &str,
3036            txid: &str,
3037            output_index: u32,
3038            _metadata: bool,
3039        ) -> Result<GASPNode, GASPError> {
3040            Ok(GASPNode {
3041                graph_id: graph_id.to_string(),
3042                raw_tx: format!("rawtx_{txid}"),
3043                output_index,
3044                proof: None,
3045                tx_metadata: None,
3046                output_metadata: None,
3047                inputs: None,
3048            })
3049        }
3050
3051        async fn find_needed_inputs(
3052            &self,
3053            _node: &GASPNode,
3054        ) -> Result<Option<GASPNodeResponse>, GASPError> {
3055            Ok(None) // No inputs needed for simple tests
3056        }
3057
3058        async fn append_to_graph(
3059            &self,
3060            node: &GASPNode,
3061            _spent_by: Option<&str>,
3062        ) -> Result<(), GASPError> {
3063            self.graphs
3064                .lock()
3065                .unwrap()
3066                .entry(node.graph_id.clone())
3067                .or_default()
3068                .push(node.clone());
3069            Ok(())
3070        }
3071
3072        async fn validate_graph_anchor(&self, _graph_id: &str) -> Result<(), GASPError> {
3073            Ok(())
3074        }
3075
3076        async fn finalize_graph(&self, graph_id: &str) -> Result<(), GASPError> {
3077            self.finalized.lock().unwrap().push(graph_id.to_string());
3078            Ok(())
3079        }
3080
3081        async fn discard_graph(&self, graph_id: &str) -> Result<(), GASPError> {
3082            self.discarded.lock().unwrap().push(graph_id.to_string());
3083            Ok(())
3084        }
3085    }
3086
3087    // ── Mock GASPRemote ────────────────────────────────────────────────
3088
3089    struct MockGASPRemote {
3090        remote_utxos: Vec<GASPOutput>,
3091        submitted: Mutex<Vec<GASPNode>>,
3092    }
3093
3094    impl MockGASPRemote {
3095        fn new(utxos: Vec<GASPOutput>) -> Self {
3096            Self {
3097                remote_utxos: utxos,
3098                submitted: Mutex::new(Vec::new()),
3099            }
3100        }
3101    }
3102
3103    #[async_trait(?Send)]
3104    impl GASPRemote for MockGASPRemote {
3105        async fn get_initial_response(
3106            &self,
3107            request: &GASPInitialRequest,
3108        ) -> Result<GASPInitialResponse, GASPError> {
3109            if request.version != GASP_VERSION {
3110                return Err(GASPError::VersionMismatch {
3111                    local: GASP_VERSION,
3112                    remote: request.version,
3113                });
3114            }
3115            let utxos: Vec<GASPOutput> = self
3116                .remote_utxos
3117                .iter()
3118                .filter(|u| u.score as u64 >= request.since)
3119                .cloned()
3120                .collect();
3121            Ok(GASPInitialResponse {
3122                utxo_list: utxos,
3123                since: request.since,
3124            })
3125        }
3126
3127        async fn get_initial_reply(
3128            &self,
3129            _response: &GASPInitialResponse,
3130        ) -> Result<GASPInitialReply, GASPError> {
3131            Ok(GASPInitialReply {
3132                utxo_list: Vec::new(),
3133            })
3134        }
3135
3136        async fn request_node(
3137            &self,
3138            graph_id: &str,
3139            txid: &str,
3140            output_index: u32,
3141            _metadata: bool,
3142        ) -> Result<GASPNode, GASPError> {
3143            Ok(GASPNode {
3144                graph_id: graph_id.to_string(),
3145                raw_tx: format!("remote_rawtx_{txid}"),
3146                output_index,
3147                proof: Some("proof_hex".to_string()),
3148                tx_metadata: None,
3149                output_metadata: None,
3150                inputs: None,
3151            })
3152        }
3153
3154        async fn submit_node(
3155            &self,
3156            node: &GASPNode,
3157        ) -> Result<Option<GASPNodeResponse>, GASPError> {
3158            self.submitted.lock().unwrap().push(node.clone());
3159            Ok(None) // No further inputs needed
3160        }
3161    }
3162
3163    // ── Tests ──────────────────────────────────────────────────────────
3164
3165    #[tokio::test]
3166    async fn test_sync_pulls_remote_utxos() {
3167        let local_storage = MockGASPStorage::new(vec![]);
3168        let remote = MockGASPRemote::new(vec![
3169            GASPOutput {
3170                txid: "tx1".to_string(),
3171                output_index: 0,
3172                score: 100.0,
3173            },
3174            GASPOutput {
3175                txid: "tx2".to_string(),
3176                output_index: 0,
3177                score: 200.0,
3178            },
3179        ]);
3180
3181        let mut gasp = GASPSync::new(
3182            Box::new(local_storage),
3183            Box::new(remote),
3184            0,
3185            "[TEST]",
3186            false,
3187        );
3188
3189        gasp.sync(None).await.unwrap();
3190
3191        assert_eq!(gasp.last_interaction, 200);
3192    }
3193
3194    #[tokio::test]
3195    async fn test_sync_skips_known_utxos() {
3196        let local_storage = MockGASPStorage::new(vec![GASPOutput {
3197            txid: "tx1".to_string(),
3198            output_index: 0,
3199            score: 100.0,
3200        }]);
3201        let remote = MockGASPRemote::new(vec![
3202            GASPOutput {
3203                txid: "tx1".to_string(),
3204                output_index: 0,
3205                score: 100.0,
3206            },
3207            GASPOutput {
3208                txid: "tx2".to_string(),
3209                output_index: 0,
3210                score: 200.0,
3211            },
3212        ]);
3213
3214        let mut gasp = GASPSync::new(
3215            Box::new(local_storage),
3216            Box::new(remote),
3217            0,
3218            "[TEST]",
3219            true, // unidirectional — skip push
3220        );
3221
3222        gasp.sync(None).await.unwrap();
3223
3224        // Only tx2 should have been ingested (tx1 was already known)
3225        // last_interaction should be 200
3226        assert_eq!(gasp.last_interaction, 200);
3227    }
3228
3229    #[tokio::test]
3230    async fn test_sync_unidirectional_skips_push() {
3231        let local_storage = MockGASPStorage::new(vec![GASPOutput {
3232            txid: "local_only".to_string(),
3233            output_index: 0,
3234            score: 50.0,
3235        }]);
3236        let remote = MockGASPRemote::new(vec![]);
3237
3238        let mut gasp = GASPSync::new(Box::new(local_storage), Box::new(remote), 0, "[TEST]", true);
3239
3240        gasp.sync(None).await.unwrap();
3241        // In unidirectional mode, local_only should NOT be pushed to remote
3242        // (no way to check directly with current mock, but no error = success)
3243    }
3244
3245    #[tokio::test]
3246    async fn test_complete_graph_finalizes_on_valid() {
3247        let storage = MockGASPStorage::new(vec![]);
3248        let remote = MockGASPRemote::new(vec![]);
3249
3250        let gasp = GASPSync::new(Box::new(storage), Box::new(remote), 0, "[TEST]", false);
3251
3252        gasp.complete_graph("test_graph.0").await.unwrap();
3253        // Storage mock always validates OK, so graph should be finalized
3254    }
3255
3256    #[tokio::test]
3257    async fn test_complete_graph_discards_on_invalid() {
3258        struct FailValidationStorage;
3259
3260        #[async_trait(?Send)]
3261        impl GASPStorage for FailValidationStorage {
3262            // Keeps no deferred graphs (bsv-low #555): never given a per-graph budget.
3263            async fn load_deferred_graphs(
3264                &self,
3265            ) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
3266                Ok(Vec::new())
3267            }
3268            async fn get_deferred_graph(
3269                &self,
3270                _: &str,
3271            ) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
3272                Ok(None)
3273            }
3274            async fn save_deferred_graph(
3275                &self,
3276                _: &crate::gasp::DeferredGraph,
3277            ) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
3278                Err(GASPError::StorageError("keeps no deferred graphs".into()))
3279            }
3280            async fn delete_deferred_graph(&self, _: &str) -> Result<(), GASPError> {
3281                Ok(())
3282            }
3283            async fn find_known_utxos(
3284                &self,
3285                _: u64,
3286                _: Option<u64>,
3287            ) -> Result<Vec<GASPOutput>, GASPError> {
3288                Ok(vec![])
3289            }
3290            async fn hydrate_gasp_node(
3291                &self,
3292                _: &str,
3293                _: &str,
3294                _: u32,
3295                _: bool,
3296            ) -> Result<GASPNode, GASPError> {
3297                unreachable!()
3298            }
3299            async fn find_needed_inputs(
3300                &self,
3301                _: &GASPNode,
3302            ) -> Result<Option<GASPNodeResponse>, GASPError> {
3303                Ok(None)
3304            }
3305            async fn append_to_graph(
3306                &self,
3307                _: &GASPNode,
3308                _: Option<&str>,
3309            ) -> Result<(), GASPError> {
3310                Ok(())
3311            }
3312            async fn validate_graph_anchor(&self, _: &str) -> Result<(), GASPError> {
3313                Err(GASPError::ValidationFailed("bad anchor".into()))
3314            }
3315            async fn finalize_graph(&self, _: &str) -> Result<(), GASPError> {
3316                panic!("should not finalize")
3317            }
3318            async fn discard_graph(&self, _: &str) -> Result<(), GASPError> {
3319                Ok(())
3320            }
3321        }
3322
3323        let gasp = GASPSync::new(
3324            Box::new(FailValidationStorage),
3325            Box::new(MockGASPRemote::new(vec![])),
3326            0,
3327            "[TEST]",
3328            false,
3329        );
3330
3331        // Should not error — discards instead of finalizing
3332        gasp.complete_graph("bad_graph.0").await.unwrap();
3333    }
3334
3335    // ── AncestorFetcher fallback tests ─────────────────────────────────
3336
3337    /// Build a minimal valid tx hex with one input referencing `source_txid`.
3338    fn make_tx_hex(source_txid: &str, source_oi: u32) -> String {
3339        let mut tx = bsv_rs::transaction::Transaction::new();
3340        tx.inputs.push(bsv_rs::transaction::TransactionInput::new(
3341            source_txid.to_string(),
3342            source_oi,
3343        ));
3344        tx.outputs.push(bsv_rs::transaction::TransactionOutput::new(
3345            100,
3346            bsv_rs::script::LockingScript::from_hex(
3347                "76a914000000000000000000000000000000000000000088ac",
3348            )
3349            .unwrap(),
3350        ));
3351        tx.to_hex()
3352    }
3353
3354    /// Storage that records appended nodes and requests exactly one ancestor
3355    /// (the first input) on the FIRST node it sees, then nothing further.
3356    struct RecordingStorage {
3357        appended: Mutex<Vec<GASPNode>>,
3358        request_once: Mutex<bool>,
3359        ancestor_outpoint: String,
3360    }
3361
3362    #[async_trait(?Send)]
3363    impl GASPStorage for RecordingStorage {
3364        // Keeps no deferred graphs (bsv-low #555): never given a per-graph budget.
3365        async fn load_deferred_graphs(
3366            &self,
3367        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
3368            Ok(Vec::new())
3369        }
3370        async fn get_deferred_graph(
3371            &self,
3372            _: &str,
3373        ) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
3374            Ok(None)
3375        }
3376        async fn save_deferred_graph(
3377            &self,
3378            _: &crate::gasp::DeferredGraph,
3379        ) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
3380            Err(GASPError::StorageError("keeps no deferred graphs".into()))
3381        }
3382        async fn delete_deferred_graph(&self, _: &str) -> Result<(), GASPError> {
3383            Ok(())
3384        }
3385        async fn find_known_utxos(
3386            &self,
3387            _: u64,
3388            _: Option<u64>,
3389        ) -> Result<Vec<GASPOutput>, GASPError> {
3390            Ok(vec![])
3391        }
3392        async fn hydrate_gasp_node(
3393            &self,
3394            _: &str,
3395            _: &str,
3396            _: u32,
3397            _: bool,
3398        ) -> Result<GASPNode, GASPError> {
3399            unreachable!()
3400        }
3401        async fn find_needed_inputs(
3402            &self,
3403            _: &GASPNode,
3404        ) -> Result<Option<GASPNodeResponse>, GASPError> {
3405            let mut once = self.request_once.lock().unwrap();
3406            if *once {
3407                *once = false;
3408                let mut requested_inputs = HashMap::new();
3409                requested_inputs.insert(
3410                    self.ancestor_outpoint.clone(),
3411                    crate::types::GASPInputRequest { metadata: false },
3412                );
3413                Ok(Some(GASPNodeResponse { requested_inputs }))
3414            } else {
3415                Ok(None)
3416            }
3417        }
3418        async fn append_to_graph(&self, node: &GASPNode, _: Option<&str>) -> Result<(), GASPError> {
3419            self.appended.lock().unwrap().push(node.clone());
3420            Ok(())
3421        }
3422        async fn validate_graph_anchor(&self, _: &str) -> Result<(), GASPError> {
3423            Ok(())
3424        }
3425        async fn finalize_graph(&self, _: &str) -> Result<(), GASPError> {
3426            Ok(())
3427        }
3428        async fn discard_graph(&self, _: &str) -> Result<(), GASPError> {
3429            Ok(())
3430        }
3431    }
3432
3433    /// Remote that always errors on `request_node` (mimics beta's HTTP 400
3434    /// "Incomplete SPV data!").
3435    struct FailingNodeRemote;
3436
3437    #[async_trait(?Send)]
3438    impl GASPRemote for FailingNodeRemote {
3439        async fn get_initial_response(
3440            &self,
3441            _: &GASPInitialRequest,
3442        ) -> Result<GASPInitialResponse, GASPError> {
3443            Ok(GASPInitialResponse {
3444                utxo_list: vec![],
3445                since: 0,
3446            })
3447        }
3448        async fn get_initial_reply(
3449            &self,
3450            _: &GASPInitialResponse,
3451        ) -> Result<GASPInitialReply, GASPError> {
3452            Ok(GASPInitialReply { utxo_list: vec![] })
3453        }
3454        async fn request_node(
3455            &self,
3456            _: &str,
3457            _: &str,
3458            _: u32,
3459            _: bool,
3460        ) -> Result<GASPNode, GASPError> {
3461            Err(GASPError::RemoteError(
3462                "Peer returned HTTP 400: Incomplete SPV data!".to_string(),
3463            ))
3464        }
3465        async fn submit_node(&self, _: &GASPNode) -> Result<Option<GASPNodeResponse>, GASPError> {
3466            Ok(None)
3467        }
3468    }
3469
3470    /// Mock fetcher that returns a pre-built ancestor rawtx (+ optional proof).
3471    struct MockFetcher {
3472        ancestor_hex: String,
3473        proof: Option<String>,
3474        called: Mutex<u32>,
3475    }
3476
3477    #[async_trait(?Send)]
3478    impl AncestorFetcher for MockFetcher {
3479        async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
3480            *self.called.lock().unwrap() += 1;
3481            Ok(FetchedAncestor {
3482                raw_tx: self.ancestor_hex.clone(),
3483                proof: self.proof.clone(),
3484            })
3485        }
3486    }
3487
3488    /// Remote that records how many times `request_node` is invoked (it would
3489    /// be a doomed peer call on the migration path) so a test can assert the
3490    /// fetcher path SKIPS the peer entirely.
3491    struct CountingNodeRemote {
3492        request_node_calls: std::rc::Rc<Mutex<u32>>,
3493    }
3494
3495    #[async_trait(?Send)]
3496    impl GASPRemote for CountingNodeRemote {
3497        async fn get_initial_response(
3498            &self,
3499            _: &GASPInitialRequest,
3500        ) -> Result<GASPInitialResponse, GASPError> {
3501            Ok(GASPInitialResponse {
3502                utxo_list: vec![],
3503                since: 0,
3504            })
3505        }
3506        async fn get_initial_reply(
3507            &self,
3508            _: &GASPInitialResponse,
3509        ) -> Result<GASPInitialReply, GASPError> {
3510            Ok(GASPInitialReply { utxo_list: vec![] })
3511        }
3512        async fn request_node(
3513            &self,
3514            _: &str,
3515            _: &str,
3516            _: u32,
3517            _: bool,
3518        ) -> Result<GASPNode, GASPError> {
3519            *self.request_node_calls.lock().unwrap() += 1;
3520            Err(GASPError::RemoteError(
3521                "Peer returned HTTP 400: Incomplete SPV data!".to_string(),
3522            ))
3523        }
3524        async fn submit_node(&self, _: &GASPNode) -> Result<Option<GASPNodeResponse>, GASPError> {
3525            Ok(None)
3526        }
3527    }
3528
3529    #[tokio::test]
3530    async fn ancestor_fetcher_none_preserves_peer_error() {
3531        // Build a tip tx whose input references some ancestor.
3532        let ancestor_hex = make_tx_hex(
3533            "1111111111111111111111111111111111111111111111111111111111111111",
3534            0,
3535        );
3536        let ancestor_txid = bsv_rs::transaction::Transaction::from_hex(&ancestor_hex)
3537            .unwrap()
3538            .id();
3539        let tip_hex = make_tx_hex(&ancestor_txid, 0);
3540
3541        let storage = RecordingStorage {
3542            appended: Mutex::new(vec![]),
3543            request_once: Mutex::new(true),
3544            ancestor_outpoint: format!("{ancestor_txid}.0"),
3545        };
3546
3547        // No fetcher configured → default path → peer error must propagate.
3548        let gasp = GASPSync::new(
3549            Box::new(storage),
3550            Box::new(FailingNodeRemote),
3551            0,
3552            "[TEST]",
3553            true,
3554        );
3555
3556        let tip_node = GASPNode {
3557            graph_id: format!("{}.0", "deadbeef"),
3558            raw_tx: tip_hex,
3559            output_index: 0,
3560            proof: None,
3561            tx_metadata: None,
3562            output_metadata: None,
3563            inputs: None,
3564        };
3565
3566        let mut seen = std::collections::HashSet::new();
3567        let result = gasp.process_incoming_node(&tip_node, None, &mut seen).await;
3568        assert!(
3569            result.is_err(),
3570            "with no fetcher, the peer error must propagate (default path unchanged)"
3571        );
3572    }
3573
3574    #[tokio::test]
3575    async fn ancestor_fetcher_some_self_heals_missing_ancestor() {
3576        let ancestor_hex = make_tx_hex(
3577            "2222222222222222222222222222222222222222222222222222222222222222",
3578            0,
3579        );
3580        let ancestor_txid = bsv_rs::transaction::Transaction::from_hex(&ancestor_hex)
3581            .unwrap()
3582            .id();
3583        let tip_hex = make_tx_hex(&ancestor_txid, 0);
3584
3585        let storage = RecordingStorage {
3586            appended: Mutex::new(vec![]),
3587            request_once: Mutex::new(true),
3588            ancestor_outpoint: format!("{ancestor_txid}.0"),
3589        };
3590
3591        let fetcher = std::rc::Rc::new(MockFetcher {
3592            ancestor_hex: ancestor_hex.clone(),
3593            proof: None,
3594            called: Mutex::new(0),
3595        });
3596
3597        let gasp = GASPSync::new(
3598            Box::new(storage),
3599            Box::new(FailingNodeRemote),
3600            0,
3601            "[TEST]",
3602            true,
3603        )
3604        .with_ancestor_fetcher(Some(fetcher.clone()));
3605
3606        let tip_node = GASPNode {
3607            graph_id: "deadbeef.0".to_string(),
3608            raw_tx: tip_hex.clone(),
3609            output_index: 0,
3610            proof: None,
3611            tx_metadata: None,
3612            output_metadata: None,
3613            inputs: None,
3614        };
3615
3616        let mut seen = std::collections::HashSet::new();
3617        gasp.process_incoming_node(&tip_node, None, &mut seen)
3618            .await
3619            .expect("with fetcher, missing ancestor self-heals from chain");
3620
3621        assert_eq!(
3622            *fetcher.called.lock().unwrap(),
3623            2,
3624            "fetcher fires twice: once to hydrate the root's OWN proof (mock returns \
3625             proof:None here, so the root stays proofless + the walk continues), then \
3626             once to self-heal the missing ancestor (#126 root proof-anchoring)"
3627        );
3628
3629        // The synthesized ancestor node must have been appended to the graph,
3630        // with proof: None so the existing recursion would continue upward.
3631        // (We can't reach into the boxed storage here, but the Ok(()) above
3632        // proves the synthesized node flowed through process_incoming_node and
3633        // append_to_graph without error.)
3634    }
3635
3636    #[tokio::test]
3637    async fn ancestor_fetcher_some_skips_peer_request_node() {
3638        // Proof of the peer-skip optimization: with a fetcher present, the
3639        // doomed peer `request_node` call must NOT fire for ancestors — the
3640        // fetcher is consulted directly. (Otherwise a deep chain pays one
3641        // sequential peer round-trip per ancestor before falling back.)
3642        let ancestor_hex = make_tx_hex(
3643            "3333333333333333333333333333333333333333333333333333333333333333",
3644            0,
3645        );
3646        let ancestor_txid = bsv_rs::transaction::Transaction::from_hex(&ancestor_hex)
3647            .unwrap()
3648            .id();
3649        let tip_hex = make_tx_hex(&ancestor_txid, 0);
3650
3651        let storage = RecordingStorage {
3652            appended: Mutex::new(vec![]),
3653            request_once: Mutex::new(true),
3654            ancestor_outpoint: format!("{ancestor_txid}.0"),
3655        };
3656
3657        let counter = std::rc::Rc::new(Mutex::new(0u32));
3658        let remote = CountingNodeRemote {
3659            request_node_calls: counter.clone(),
3660        };
3661        let fetcher = std::rc::Rc::new(MockFetcher {
3662            ancestor_hex: ancestor_hex.clone(),
3663            proof: None,
3664            called: Mutex::new(0),
3665        });
3666
3667        let gasp = GASPSync::new(Box::new(storage), Box::new(remote), 0, "[TEST]", true)
3668            .with_ancestor_fetcher(Some(fetcher.clone()));
3669
3670        let tip_node = GASPNode {
3671            graph_id: "deadbeef.0".to_string(),
3672            raw_tx: tip_hex,
3673            output_index: 0,
3674            proof: None,
3675            tx_metadata: None,
3676            output_metadata: None,
3677            inputs: None,
3678        };
3679
3680        let mut seen = std::collections::HashSet::new();
3681        gasp.process_incoming_node(&tip_node, None, &mut seen)
3682            .await
3683            .expect("self-heal via fetcher should succeed");
3684
3685        assert_eq!(
3686            *counter.lock().unwrap(),
3687            0,
3688            "peer request_node must be SKIPPED for ancestors when a fetcher is present"
3689        );
3690        assert_eq!(
3691            *fetcher.called.lock().unwrap(),
3692            2,
3693            "fetcher fires twice: root's OWN proof hydration (#126) + serving the \
3694             ancestor directly (peer request_node still skipped)"
3695        );
3696    }
3697
3698    #[test]
3699    fn test_parse_outpoint() {
3700        let (txid, oi) = parse_outpoint("abc123.5").unwrap();
3701        assert_eq!(txid, "abc123");
3702        assert_eq!(oi, 5);
3703
3704        assert!(parse_outpoint("no_dot").is_none());
3705        assert!(parse_outpoint("abc.notanum").is_none());
3706    }
3707
3708    #[tokio::test]
3709    async fn test_gasp_storage_is_object_safe() {
3710        let storage: Box<dyn GASPStorage> = Box::new(MockGASPStorage::new(vec![]));
3711        let utxos = storage.find_known_utxos(0, None).await.unwrap();
3712        assert!(utxos.is_empty());
3713    }
3714
3715    #[tokio::test]
3716    async fn test_gasp_remote_is_object_safe() {
3717        let remote: Box<dyn GASPRemote> = Box::new(MockGASPRemote::new(vec![]));
3718        let response = remote
3719            .get_initial_response(&GASPInitialRequest {
3720                version: 1,
3721                since: 0,
3722                limit: None,
3723            })
3724            .await
3725            .unwrap();
3726        assert!(response.utxo_list.is_empty());
3727    }
3728
3729    #[tokio::test]
3730    async fn test_version_mismatch_error() {
3731        let remote = MockGASPRemote::new(vec![]);
3732        let result = remote
3733            .get_initial_response(&GASPInitialRequest {
3734                version: 99,
3735                since: 0,
3736                limit: None,
3737            })
3738            .await;
3739        assert!(result.is_err());
3740        assert!(matches!(
3741            result.unwrap_err(),
3742            GASPError::VersionMismatch { .. }
3743        ));
3744    }
3745}