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/// Pull 100 outputs per request under the Worker's 30-second peer budget.
26/// Reference: overlay-services@480d77a src/Engine.ts:29,895 requests 10,000
27/// through src/GASP/OverlayGASPRemote.ts. Smaller requests bound each reply
28/// and allow a slow peer's completed prefix to persist across ticks.
29/// The responder's default stays unchanged for wire compatibility.
30pub const ENGINE_GASP_SYNC_PAGE_LIMIT: u64 = 100;
31
32// ============================================================================
33// Per-peer sync budgets + dead-peer quarantine (bsv-low#302)
34// ============================================================================
35
36/// PURE (bsv-low#257/#302): race `fut` against `deadline`; `None` = the
37/// deadline won and `fut` was DROPPED (its in-flight work cancelled).
38/// Injectable deadline so the control flow is natively unit-tested — the
39/// house idiom generalized out of `overlay-cloudflare`'s scheduled handler
40/// (which passes its platform `sleep_ms`) so the ENGINE's per-peer GASP
41/// budget can reuse it.
42pub async fn race_or_deadline<F, D, T>(fut: F, deadline: D) -> Option<T>
43where
44    F: std::future::Future<Output = T>,
45    D: std::future::Future<Output = ()>,
46{
47    let mut fut = std::pin::pin!(fut);
48    let mut deadline = std::pin::pin!(deadline);
49    std::future::poll_fn(move |cx| {
50        if let std::task::Poll::Ready(v) = fut.as_mut().poll(cx) {
51            return std::task::Poll::Ready(Some(v));
52        }
53        if deadline.as_mut().poll(cx).is_ready() {
54            return std::task::Poll::Ready(None);
55        }
56        std::task::Poll::Pending
57    })
58    .await
59}
60
61/// `deadline`, LATCHED: once it fell due it answers `Ready` on every poll and
62/// its inner future is never polled again (bsv-low #555, found by the
63/// delta-2 fold). The worker's sleeps are `async fn` futures, which PANIC
64/// when polled after they completed, and a per-graph deadline is polled
65/// again after it fell due (the shared resume deadline, kept for the next
66/// record; the half deadline of D2-L1).
67pub fn latched(deadline: crate::engine::SleepFuture) -> crate::engine::SleepFuture {
68    let mut inner = Some(deadline);
69    Box::pin(std::future::poll_fn(move |cx| {
70        let Some(deadline) = inner.as_mut() else {
71            return std::task::Poll::Ready(());
72        };
73        if deadline.as_mut().poll(cx).is_ready() {
74            inner = None;
75            return std::task::Poll::Ready(());
76        }
77        std::task::Poll::Pending
78    }))
79}
80
81/// What makes a deadline COOPERATIVE around a write (bsv-low #552, the lens
82/// fold's HIGH-1). `Engine::submit` is several storage writes (mark spent,
83/// delete the stale coin, insert the outputs, notify, record applied), each
84/// an await on D1, and a future dropped between two of them leaves a head
85/// chain with NO head: the old one deleted, the new one never inserted, and
86/// no applied row to say so.
87///
88/// The writer opens a [`WriteSection`] around ONE transaction's submit and
89/// asks [`SubmitGate::stop_if_due`] between two of them.
90/// [`race_or_deadline_guarded`] does not drop its future while a section is
91/// open: it notes the deadline as due and keeps polling until the section
92/// closes, so the wait is bounded by the writes of one transaction. What the
93/// deadline leaves is then a prefix of whole transactions, never part of one.
94/// The race does not bound a write that never answers: the writer does
95/// (bsv-low #559, `Engine::set_finalize_submit_budget`: the engine bounds
96/// each storage call and hook inside the section, and a call that never
97/// answers is that call's fault; the submit itself is never dropped).
98#[derive(Debug, Default)]
99pub struct SubmitGate {
100    writing: std::cell::Cell<u32>,
101    due: std::cell::Cell<bool>,
102}
103
104impl SubmitGate {
105    /// Open a section no guarded race may drop. Closed when the returned
106    /// value is dropped.
107    pub fn write_section(&self) -> WriteSection<'_> {
108        self.writing.set(self.writing.get() + 1);
109        WriteSection(self)
110    }
111
112    /// Whether a guarded race's deadline fell due while a section was open:
113    /// the race is waiting for the writer to stop.
114    pub fn deadline_is_due(&self) -> bool {
115        self.due.get()
116    }
117
118    /// The writer's question at a transaction boundary, with NO section open.
119    /// If a deadline is due this never returns: it hands the future back to
120    /// the race, which drops it on this very poll (only a guarded race sets
121    /// the flag, and it clears it when it ends).
122    pub async fn stop_if_due(&self) {
123        if self.due.get() {
124            std::future::pending::<()>().await;
125        }
126    }
127}
128
129/// An open write section of a [`SubmitGate`].
130#[must_use = "the section closes when this is dropped"]
131pub struct WriteSection<'g>(&'g SubmitGate);
132
133impl Drop for WriteSection<'_> {
134    fn drop(&mut self) {
135        self.0.writing.set(self.0.writing.get().saturating_sub(1));
136    }
137}
138
139/// [`race_or_deadline`] that never drops `fut` inside a [`WriteSection`] of
140/// `gate`. A deadline that falls due while a section is open is remembered
141/// (and shown to the writer, [`SubmitGate::deadline_is_due`]); `fut` is
142/// polled on, by its own wakers, and dropped at its first pending point
143/// outside a section. `None` then means what it meant: the deadline won.
144pub async fn race_or_deadline_guarded<F, D, T>(fut: F, deadline: D, gate: &SubmitGate) -> Option<T>
145where
146    F: std::future::Future<Output = T>,
147    D: std::future::Future<Output = ()>,
148{
149    // The flag never outlives the race that set it, however the race ends
150    // (an outer drop included): a stale one would park the next writer.
151    struct ClearDue<'g>(&'g SubmitGate);
152    impl Drop for ClearDue<'_> {
153        fn drop(&mut self) {
154            self.0.due.set(false);
155        }
156    }
157    let _clear = ClearDue(gate);
158    let mut fut = std::pin::pin!(fut);
159    let mut deadline = std::pin::pin!(deadline);
160    let mut due = false;
161    std::future::poll_fn(move |cx| {
162        if let std::task::Poll::Ready(v) = fut.as_mut().poll(cx) {
163            return std::task::Poll::Ready(Some(v));
164        }
165        if !due && deadline.as_mut().poll(cx).is_ready() {
166            due = true;
167        }
168        if due {
169            if gate.writing.get() == 0 {
170                return std::task::Poll::Ready(None);
171            }
172            // `fut` is parked inside a write: its own waker brings us back.
173            gate.due.set(true);
174        }
175        std::task::Poll::Pending
176    })
177    .await
178}
179
180/// Consecutive all-failure syncs after which a peer is QUARANTINED
181/// (bsv-low#302). At the production `*/15` cron cadence 8 consecutive
182/// failures ≈ 2 hours of unbroken unreachability — comfortably past any
183/// transient blip (deploy, restart, brief network fault, one bad tick),
184/// while still cutting a genuinely dead peer's budget burn off the same
185/// day it dies.
186pub const PEER_QUARANTINE_THRESHOLD: u64 = 8;
187
188/// Re-probe interval for a quarantined peer, seconds (6 h ≈ every 24 cron
189/// ticks). Quarantine is NEVER a deletion: once `secs_since_last_attempt`
190/// reaches this age the peer gets exactly ONE fresh sync attempt — success
191/// resets its failure count to 0 (full re-admission), failure re-arms the
192/// quarantine for another window. A peer that died transiently therefore
193/// re-admits itself automatically on the first re-probe after it recovers.
194pub const PEER_QUARANTINE_REPROBE_SECS: u64 = 6 * 3600;
195
196/// Consecutive YIELDLESS syncs of a (peer, topic) allowed before each further
197/// one is a FAILED attempt for the quarantine (bsv-low #555, the delta fold's
198/// D-M2). A sync is yieldless when the peer served it work it did not hold
199/// ([`GASPSync::graphs_attempted`] > 0) and it finalized no graph and moved no
200/// cursor; it then counts a success only because a walk PROGRESSED (a node
201/// appended), and a node is the peer's word: a hostile peer that lists one
202/// fresh fabricated UTXO a tick, serves its root and hangs on its input kept
203/// its slice for ever. Past this many in a row such a sync is a failure, so
204/// that peer is quarantined after this many plus
205/// [`PEER_QUARANTINE_THRESHOLD`] syncs (20, 5 h at `*/15`) and gets one probe
206/// per [`PEER_QUARANTINE_REPROBE_SECS`] after that. A sync that finalized a
207/// graph or moved the cursor resets the count; a quiet one leaves it.
208/// A quarantined peer is re-admitted only by such a yield (the delta-4
209/// fold's D4-L2): its sync that did not fail and yielded nothing, an empty
210/// listing at its probe included, is not recorded, so its failures stay and
211/// it is attended on each tick until it yields or fails again.
212///
213/// The bound is in SYNCS AND in TIME (the delta-2 fold's D2-M1): a yieldless
214/// sync is a failure only when the streak holds more than this many syncs
215/// AND began at least [`PEER_YIELDLESS_SECS_ALLOWED`] ago
216/// ([`yieldless_sync_failed`]). Counted in syncs alone, 12 was three hours at
217/// `*/15` and twelve minutes at LOW's one tick a minute, under the tail of a
218/// block interval: an honest peer whose only new work was #582's unproven
219/// head was quarantined while it waited for its block.
220///
221/// Why 12 and 3 h: an honest deep graph converges well inside both. The
222/// measured one (#582) is an UNPROVEN head over unproven ancestry: its root
223/// is re-asked at each resume and the pass after its block lands restarts the
224/// walk from a proven root and completes it (`root_proven`), one or two passes
225/// after a block that comes in ~10 min on average (past 3 h about once in
226/// 10^8 blocks by the exponential model, at any cadence). Walked to its end
227/// instead, 12 passes are about 150 nodes on the Cloudflare worker (15 s a
228/// pass at 1.2 s a request) and 600 on LOW's node (60 s). Applied only with a
229/// per-graph budget (`Engine::set_graph_budget`): without one no walk is
230/// deferred and the quarantine is the one of #302.
231pub const PEER_YIELDLESS_SYNCS_ALLOWED: u64 = 12;
232
233/// The AGE a yieldless streak must reach, seconds since its first yieldless
234/// sync, before a yieldless sync is a failure (3 h; the delta-2 fold's
235/// D2-M1, see [`PEER_YIELDLESS_SYNCS_ALLOWED`]). The storage keeps the
236/// streak's start ([`crate::storage::PeerYieldStreak`]) on its own clock.
237pub const PEER_YIELDLESS_SECS_ALLOWED: u64 = 3 * 3600;
238
239/// The DECAY of a yieldless streak (the delta-2 fold's D2-M1): a yieldless
240/// sync more than this long (6 h, [`PEER_QUARANTINE_REPROBE_SECS`]) after the
241/// streak's LAST yieldless sync starts a new streak (count 1, its start now)
242/// instead of adding to the old one. Quiet syncs in between leave the streak
243/// as it is (a quiet sync is not a yield), so an honest peer whose rare
244/// graphs that never land are hours apart is not counted across days. The
245/// cost, stated: a peer yieldless for 3 h, quiet for 6 h, and so on, keeps
246/// its slice during the yieldless hours (a third of its ticks at most), as
247/// #302's own accepted residual of one success in every eight.
248///
249/// Only for a peer that is NOT failed (`consecutive_failures` 0; the delta-3
250/// fold's D3-M1). This window equals the reprobe's, so the probe of a peer
251/// quarantined by this bound always came more than 6 h after its last
252/// yieldless sync: it started a fresh streak (count 1, not failed), the sync
253/// "progressed", the failures went to 0 and the quarantine lifted at every
254/// probe (half the ticks at `*/15`). The probe of a failed peer continues
255/// its streak and fails, so it gets one probe per reprobe window.
256pub const PEER_YIELDLESS_DECAY_SECS: u64 = PEER_QUARANTINE_REPROBE_SECS;
257
258/// PURE rule of the yieldless bound (bsv-low #555, the delta-2 fold's
259/// D2-M1): a yieldless sync is a FAILED attempt IFF its streak holds more
260/// than [`PEER_YIELDLESS_SYNCS_ALLOWED`] syncs AND is at least
261/// [`PEER_YIELDLESS_SECS_ALLOWED`] old. A streak of unknown age (a backend
262/// with no clock) is never past (fail-safe, as #302's unknown attempt age).
263pub fn yieldless_sync_failed(streak: &crate::storage::PeerYieldStreak) -> bool {
264    streak.yieldless_syncs > PEER_YIELDLESS_SYNCS_ALLOWED
265        && streak
266            .secs_since_first
267            .is_some_and(|secs| secs >= PEER_YIELDLESS_SECS_ALLOWED)
268}
269
270/// The NORMALIZED ORIGIN of a peer URL (bsv-low #555, the delta-2 fold's
271/// D2-M2): its host, lowercased, with no scheme, user, port, path, query or
272/// fragment, and no trailing dot. The key of a peer's quarantine and
273/// yieldless streak (the engine passes it to the three peer-health methods
274/// of [`crate::storage::Storage`]) and of its share of a storage's deferred
275/// graph ceiling (the worker's `origin` column): keyed on the URL string, one
276/// server was eight "hosts" for eight adverts (`?1`, `:8443`, a trailing
277/// slash, `#x`), each with its own share and a fresh streak. A subdomain is
278/// another host still (no public suffix list here): stated. The cursor and
279/// the deferred records stay keyed by the URL the peer is synced at.
280pub fn peer_origin(url: &str) -> String {
281    peer_authority(url).1
282}
283
284/// `(scheme, host, port)` of a peer URL: the scheme lowercased (`https`
285/// when absent), the host as [`peer_origin`] gives it, the port as written
286/// (`None` when absent, empty, or the scheme's default: `443` for `https`,
287/// `80` for `http`).
288fn peer_authority(url: &str) -> (String, String, Option<String>) {
289    let url = url.trim();
290    let (scheme, rest) = url.split_once("://").unwrap_or(("https", url));
291    let scheme = scheme.to_ascii_lowercase();
292    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
293    let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
294    let (host, port) = match host_port.strip_prefix('[') {
295        // `[::1]:8443`: the bracketed address, brackets kept.
296        Some(v6) => match v6.split_once(']') {
297            Some((addr, after)) => (format!("[{addr}]"), after.strip_prefix(':')),
298            None => (host_port.to_string(), None),
299        },
300        None => match host_port.split_once(':') {
301            Some((host, port)) => (host.to_string(), Some(port)),
302            None => (host_port.to_string(), None),
303        },
304    };
305    let default_port = match scheme.as_str() {
306        "https" => "443",
307        "http" => "80",
308        _ => "",
309    };
310    let port = port.filter(|p| !p.is_empty() && *p != default_port);
311    (
312        scheme,
313        host.trim_end_matches('.').to_ascii_lowercase(),
314        port.map(str::to_string),
315    )
316}
317
318/// The peers of a SHIP-discovered topic, ONE per [`peer_origin`] (bsv-low
319/// #555, the delta-2 fold's D2-M2; restored by the delta-4 fold, D4-M1): each
320/// advertised domain is canonicalized to `scheme://host[:port]` (the scheme
321/// and host lowercased; path, query, fragment, user, a trailing dot and the
322/// scheme's default port dropped), and of the spellings of one origin the
323/// first by `https`, then no explicit port, then the string is kept. So
324/// eight adverts of one server (`/?1`, `/?2`, `/#x`, `:8443`, ...) are one
325/// peer synced once a tick, and a stranger's spelling of an honest host (a
326/// query, a dead port) cannot stand beside that host's own advert under its
327/// quarantine key. The delta-3 fold kept one peer per `scheme://host[:port]`
328/// while the failures stayed on one row per origin (D4-M1): eight adverts of
329/// dead ports of an honest host's name quarantined the honest peer, and
330/// seven were never quarantined, a slice each on every tick. The LIMIT,
331/// stated (the delta-3 lens's D3-L1): two overlays of one host on two ports,
332/// on one SHIP topic, are one peer (the default port's), so a stranger's
333/// default-port spelling displaces an honest overlay that serves only on
334/// another port. Sorted, for a stable order.
335pub fn ship_peers_by_origin<I: IntoIterator<Item = String>>(domains: I) -> Vec<String> {
336    type Rank = (bool, bool, String);
337    let mut by_origin: std::collections::BTreeMap<String, (Rank, String)> =
338        std::collections::BTreeMap::new();
339    for domain in domains {
340        let (scheme, origin, port) = peer_authority(&domain);
341        if origin.is_empty() {
342            continue;
343        }
344        let canonical = match &port {
345            Some(port) => format!("{scheme}://{origin}:{port}"),
346            None => format!("{scheme}://{origin}"),
347        };
348        let candidate = (scheme != "https", port.is_some(), canonical.clone());
349        match by_origin.get(&origin) {
350            Some((kept, _)) if *kept <= candidate => {}
351            _ => {
352                by_origin.insert(origin, (candidate, canonical));
353            }
354        }
355    }
356    let mut peers: Vec<String> = by_origin.into_values().map(|(_, url)| url).collect();
357    peers.sort();
358    peers
359}
360
361/// PURE quarantine rule (bsv-low#302): a peer is skipped IFF it has hit
362/// [`PEER_QUARANTINE_THRESHOLD`] consecutive failures AND its last attempt
363/// is younger than [`PEER_QUARANTINE_REPROBE_SECS`]. A peer with no
364/// recorded attempt (`secs_since_last_attempt == None` — pristine, or a
365/// backend without peer-health tracking) is NEVER quarantined, and an old
366/// enough last attempt always re-opens one probe — fail-safe in the
367/// "attempt more, never strand a peer forever" direction.
368///
369/// ACCEPTED RESIDUAL (bsv-low#304 gate M-3, documented — not built): a
370/// peer that ALTERNATES one success into every window of ≤7 failures
371/// resets its streak each time and is never quarantined, burning up to one
372/// per-peer budget slice (30 s) per (host, topic) per tick forever. Why
373/// accepted: (a) the burn is bounded by the per-peer budget times the
374/// configured peer set, all inside the 240 s outer step belt — the #257
375/// unbounded-hang class cannot recur; (b) sustaining it requires the peer
376/// to actually COMPLETE a real sync every few ticks, i.e. behave as a
377/// (slow) live peer — indistinguishable in principle from a genuinely
378/// flaky honest peer, which the rule must never strand; (c) LOW's money
379/// topics do not GASP-sync at all (no `sync_configuration` peers in prod),
380/// so the exposure is cron-budget noise, not a money surface. A
381/// streak-decay or success-ratio rule could narrow it later if a real
382/// abuser appears.
383pub fn peer_sync_quarantined(health: &crate::storage::PeerSyncHealth) -> bool {
384    health.consecutive_failures >= PEER_QUARANTINE_THRESHOLD
385        && health
386            .secs_since_last_attempt
387            .is_some_and(|secs| secs < PEER_QUARANTINE_REPROBE_SECS)
388}
389
390// ============================================================================
391// Deferred graphs (bsv-low #555)
392// ============================================================================
393
394/// Default per-GRAPH call budget (bsv-low #555): requests to the peer, or
395/// chain fetches, that ONE graph's walk may make in one pass before it is
396/// deferred. With [`DEFAULT_GRAPH_BUDGET_MS`]: at the 1.2 s per request
397/// measured on beta (2026-10-09, #582) 100 calls take about 120 s, so on a
398/// slow peer the time binds first (about 50 nodes a pass) and on a fast one
399/// the calls do, which also bounds how much a record grows per pass.
400pub const DEFAULT_GRAPH_BUDGET_CALLS: u32 = 100;
401
402/// Default per-GRAPH time budget, ms (bsv-low #555): half of the 120 s
403/// topic slice of LOW's node (one peer), so a deep graph takes at most half
404/// of its topic's slice and the UTXOs after it keep the other half. A caller
405/// keeps it BELOW its per-peer budget (`Engine::set_peer_sync_budget`); a
406/// graph the per-peer deadline cuts first is deferred all the same.
407pub const DEFAULT_GRAPH_BUDGET_MS: u64 = 60_000;
408
409/// Default per-GRAPH bytes budget (bsv-low #586): the bytes of the nodes ONE
410/// graph's walk may be SERVED in one pass before it is deferred, counted as
411/// the peer serves them and as a record keeps them (the hex of each node's
412/// raw transaction and of its proof, every answer counted, a repeat too).
413/// A BUDGET PER PASS, not a limit: the walk makes at least one request a
414/// pass whatever its size, and what the pass leaves is resumed.
415///
416/// 917,504 bytes: a budget per pass and nothing else (bsv-low #585). It was
417/// derived from a 1 MiB cap on a record (seven eighths of it, the lens fold's
418/// E586-L1) so that the pass it cut left a record that fitted; a record has
419/// no cap since #585 (the storage keeps one of any size, the worker in
420/// chunks), so the figure is kept for what it measures, 18 heads of 26 KB a
421/// fresh pass, and no longer sits "under" anything. A consumer may set any
422/// other ([`GraphBudget::max_bytes_fetched`]).
423pub const DEFAULT_GRAPH_BUDGET_BYTES: u64 = 917_504;
424
425/// Default per-GRAPH node budget (bsv-low #586): the nodes ONE graph's walk
426/// may APPEND in one pass before it is deferred (five times the 12 of
427/// Zanaadu's largest legitimate graph). As [`DEFAULT_GRAPH_BUDGET_BYTES`], a
428/// budget per pass that resumes; it also bounds the JSON a pass adds to a
429/// record around its nodes' hex, which the bytes limb does not count.
430pub const DEFAULT_GRAPH_BUDGET_NODES: u32 = 64;
431
432/// Reset a partial walk after this many passes (`max_passes`). If its cursor
433/// could have passed, keep a root retry obligation in the same bounded slot.
434/// One hour at a one-minute cadence, fifteen at `*/15`.
435pub const DEFERRED_GRAPH_MAX_PASSES: u32 = 60;
436
437/// The most records one (peer, topic) may hold. A new deferral past it is
438/// not saved (reason `too_many`) and its walk goes on under the per-peer
439/// budget alone, as before #555 (the lens fold's L4): a peer that serves many
440/// deep graphs costs at most 16 records. A storage may refuse a record at a
441/// ceiling of its own ([`DeferredGraphSave::AtCeiling`]), counted `too_many`
442/// too (the worker's global ceiling, the lens fold's M3). A record has NO
443/// byte bound (bsv-low #585: it had 1 MiB, `too_big`): a graph of any size
444/// is deferred and resumed whole, and the storage's ceiling is a BUDGET of
445/// room, under which a RESUMED walk keeps the record it holds.
446pub const DEFERRED_GRAPHS_PER_PEER_TOPIC: usize = 16;
447
448/// Reset a resumed partial walk after this many consecutive idle faults.
449/// Keep the root retry obligation if the cursor could have passed it. These
450/// attempts remain yieldless and count toward peer quarantine.
451pub const DEFERRED_GRAPH_MAX_IDLE_FAULTS: u32 = 3;
452
453/// Three consecutive cuts by the graph's OWN deadline (`time`) distinguish a
454/// repeatedly stalled root from a single slow response. Keep a bounded retry
455/// record meanwhile; only the third cut permits the cursor to pass a root
456/// with no fetched node. The per-PEER deadline (`peer_deadline`) is not the
457/// graph's: the graph in flight when the peer's slice ends is held at once
458/// (see [`GASPSync::defer_in_flight`]).
459pub const DEADLINE_STALL_DEFERRAL_RUNS: u32 = 3;
460
461/// How long a backend remembers an outpoint the walk refused as expired,
462/// past `max(expiry, refusal)`: seven days (engine 0.1.8). A peer keeps
463/// listing a dead advert while it is unspent there, so without the memo
464/// every run fetched its node to refuse it again (fact 3 of the 0.1.8
465/// brief: about 470 node fetches, most of a 30 s slice). With it, one fetch
466/// a week per dead outpoint; the sweep bounds the table by age.
467pub const EXPIRED_OUTPOINT_MEMO_SECS: u64 = 7 * 24 * 3600;
468
469/// The most expired roots a backend remembers for one topic: about nine
470/// times the 2,282 dead adverts of the old overlay's UHRP history. Past it
471/// the oldest rows go; a forgotten one costs one node fetch again.
472pub const EXPIRED_OUTPOINT_MEMO_MAX_ROWS: usize = 20_000;
473
474/// A root whose walk is dropped by a FAULT (not a deadline, not a bound) in
475/// this many consecutive runs is recorded unanchorable (engine 0.1.9): a
476/// record `unanchorable:<class>` the cursor passes, re-walked on the long
477/// backoff [`UNANCHORABLE_RETRY_SECS`]. Three, the deadline rule's number
478/// ([`DEADLINE_STALL_DEFERRAL_RUNS`]): one fault is the moment's (a 5xx, a
479/// rung's rate limit, a courier budget earlier graphs spent); a fault that
480/// survives three runs, each with a fresh peer slice and a fresh courier
481/// budget and each walking this root ahead of the pages, is the graph's own.
482/// Before 0.1.9 such a root held the cursor below it for ever (the 0.1.4
483/// rule) and every later output waited on a captain's hand. A held record
484/// whose RESUMED pass is dropped by such a fault counts the same runs on its
485/// own slot (engine 0.1.11): on 0.1.10 a root retry obligation whose parent
486/// no rung served was dropped and saved again as it was on every run, never
487/// struck (fact 3 of the 0.1.11 brief).
488pub const UNANCHORABLE_AFTER_FAULTED_RUNS: u32 = 3;
489
490/// How long an unanchorable root waits before its next walk: six hours. What
491/// it lacks lands on that scale or not at all: an ancestor a rung indexes
492/// once mined (minutes to an hour), a peer that gains the history, a proof.
493/// Four walks a day of each of at most [`DEFERRED_GRAPHS_PER_PEER_TOPIC`]
494/// records is 64 walks a day for a (peer, topic) at worst, against 1,440
495/// ticks a day at a one-minute cadence. A later success admits it as any
496/// root and clears the record; nothing is admitted unanchored (T2).
497pub const UNANCHORABLE_RETRY_SECS: u64 = 6 * 3600;
498
499/// Four retries, six hours apart, cover one day after an unanchorable verdict.
500/// A spent allowance is a durable fact until a new score or operator reset.
501pub const UNANCHORABLE_RETRIES: u32 = 4;
502pub const DEAD_REASON: &str = "dead:";
503
504/// The least time between two counted absences of one root (engine 0.1.15,
505/// bsv-overlay-cloudflare #56): an absence is the advertising peer's word
506/// ([`fault_class::NOT_FOUND`]), and three of them strike the root
507/// `unanchorable:not_found` only when they were asked on three runs with a
508/// gap. Sixty seconds: two of the stack's 30 s per-peer slices
509/// (`GASP_PEER_SYNC_BUDGET_MS`), one tick at its one-minute cadence, so a
510/// backfill call walking slice after slice cannot count one root three
511/// times in one breath. Until it passes the root's record costs no request
512/// and the cursor holds below it, as for any fault under the threshold.
513pub const ABSENCE_GAP_SECS: u64 = 60;
514
515/// A held (passable) record resumed this many CONSECUTIVE passes with no
516/// node appended goes to the long backoff (engine 0.1.10): it is not resumed
517/// again until [`IDLE_RESUME_RETRY_SECS`] have passed, so the resumes' shared
518/// budget goes to records that progress and the peer's slice to its page.
519/// Three, the deadline rule's number ([`DEADLINE_STALL_DEFERRAL_RUNS`]) and
520/// the idle-fault rule's ([`DEFERRED_GRAPH_MAX_IDLE_FAULTS`]): one empty
521/// pass is the moment's (a slow peer, a budget the earlier resumes spent),
522/// three in a row, each with a fresh slice, are the record's own. On 0.1.9 a
523/// root retry obligation the peer no longer serves (fact 3 of the 0.1.10
524/// brief: 14 of them, `nodes 0`, `not_held` on every run) was resumed on
525/// every tick for ever, and they took the slice the page's fresh roots
526/// needed. A resume that appends a node resets the count; one past the
527/// backoff that appends nothing goes back to it at once.
528pub const IDLE_RESUMES_BEFORE_BACKOFF: u32 = 3;
529
530/// How long a record sent to the long backoff waits before its next resume:
531/// [`UNANCHORABLE_RETRY_SECS`] (six hours), as an unanchorable root does, for
532/// the same reason: what it lacks (a peer that serves the root again, a
533/// proof, an ancestor) lands on that scale or not at all. The record is
534/// kept (bsv-low #555's resume: its cursor may have passed it) and costs
535/// four resumes a day instead of 1,440.
536pub const IDLE_RESUME_RETRY_SECS: u64 = UNANCHORABLE_RETRY_SECS;
537
538/// The share of the per-PEER sync budget the resumes of one pass may take
539/// together, in percent (engine 0.1.11): the held records are walked ahead
540/// of the page, so without a share of its own the page got what they left.
541/// The rest of the slice is the page's.
542///
543/// 33: on the stack's worker the per-peer budget is 30,000 ms
544/// (`GASP_PEER_SYNC_BUDGET_MS`) and one graph's own budget 15,000 ms
545/// (`GASP_GRAPH_BUDGET_MS`), which was the resumes' shared deadline (the lens
546/// fold's M1) and started at the opening resume, so the reads and writes of
547/// the storage between resumes (a record read, its save or drop, the
548/// settled read: about five a record) came on top of it, outside any race.
549/// The share is [`resume_share_ms`]: `min(30,000 * 33 / 100, 15,000)` =
550/// 9,900 ms, started when the held loop starts, so those storage calls are
551/// inside it. The page keeps 20,100 ms: one fresh graph's whole 15,000 ms
552/// budget and 5,100 ms for its listing pages, the finalize submits and the
553/// storage. The courier's rungs (bitails, woc, bananablocks, asked in turn)
554/// have no timeout of their own (the worker's `http_get` sets none): a rung
555/// is cut only by the deadline its step races, which inside the held loop is
556/// this share. The worker sizes a courier attempt at 500 ms
557/// (`GASP_PROOF_FETCH_BUDGET`, a planning figure, not a measurement), so the
558/// share holds about 20 attempts: about 6 resumes of fact 3's shape (a root
559/// request, a root proof ask and an ancestor's three rungs), the rest held
560/// back to the next run. A record held back by the share is not resumed, not
561/// a failed UTXO, not a stalled walk and not an idle resume.
562pub const RESUMES_SHARE_OF_PEER_BUDGET_PERCENT: u64 = 33;
563
564/// The resumes' share of a pass, ms (engine 0.1.11): the
565/// [`RESUMES_SHARE_OF_PEER_BUDGET_PERCENT`] of the per-peer budget, never
566/// more than one graph's own budget (the shared deadline before 0.1.11).
567pub fn resume_share_ms(peer_budget_ms: u64, graph_budget_ms: u64) -> u64 {
568    (peer_budget_ms.saturating_mul(RESUMES_SHARE_OF_PEER_BUDGET_PERCENT) / 100).min(graph_budget_ms)
569}
570
571/// The reason prefix of a root's record while its fault streak is under
572/// [`UNANCHORABLE_AFTER_FAULTED_RUNS`]: not passable, the cursor holds.
573pub const FAULT_RETRY_REASON: &str = "retry:fault:";
574
575/// The reason prefix of an unanchorable root's record: passable.
576pub const UNANCHORABLE_REASON: &str = "unanchorable:";
577
578/// The fault classes a dropped root is named with (engine 0.1.9).
579pub mod fault_class {
580    /// The courier (the chain ancestor fetcher, its rungs) faulted or could
581    /// not serve an ancestor.
582    pub const COURIER: &str = "courier";
583    /// Absence (engine 0.1.15, bsv-overlay-cloudflare #56): the advertising
584    /// peer answered it does not hold the root, or a transaction an
585    /// UNPROVEN node of the graph needs (`NodeNotFound`), so nothing can
586    /// anchor; or, beside it, every configured raw rung answered absent
587    /// (`DefinitiveNotFound`). One run counts once whoever said it. The
588    /// peers' word decides: a rung, when one is configured, fetches and
589    /// never strikes by itself (on 0.1.12 to 0.1.14 the rungs' absence
590    /// struck `unanchorable:not_found` at once). Three runs at least
591    /// [`super::ABSENCE_GAP_SECS`] apart strike it, as any striking class.
592    pub const NOT_FOUND: &str = "not_found";
593    /// The class a peer's `NodeNotFound` carried on 0.1.9 to 0.1.14. Kept
594    /// so the records written then read as they were; a peer's word of
595    /// absence is [`NOT_FOUND`] since 0.1.15.
596    pub const NOT_SERVED: &str = "not_served";
597    /// The peer faulted serving a node (a transport fault, a 5xx, a body
598    /// that does not parse). Named, never a strike: a peer that answers
599    /// with faults is the quarantine's to judge (bsv-low #302, #555's D-M1:
600    /// a 5xx keeps no record), not a reason to pass the roots it lists.
601    pub const PEER: &str = "peer";
602    /// A served node was refused when appended to the graph (it does not
603    /// parse or verify as the graph needs it).
604    pub const VERIFICATION: &str = "verification";
605    /// Any other failure (a storage fault of ours, a bound): named, never a
606    /// strike against the root.
607    pub const OTHER: &str = "other";
608    /// The route to the peer faulted before the peer was reached (engine
609    /// 0.2.0, #57; `GASPError::RouteFault`): named, never a strike, as it
610    /// says nothing of the root or of the peer.
611    pub const ROUTE: &str = "route";
612
613    /// Whether a fault of `class` counts a faulted run of its root: the
614    /// three of the 0.1.9 brief (the courier, an input nobody served, a
615    /// verification refusal), never a peer's fault of the moment.
616    pub fn strikes(class: &str) -> bool {
617        matches!(class, COURIER | NOT_SERVED | VERIFICATION | NOT_FOUND)
618    }
619}
620
621/// One root a sync dropped, named with why (engine 0.1.9): what the sync's
622/// accounting and the tick's summary line carry, once per (peer, root) per
623/// run.
624#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
625pub struct FaultedRoot {
626    /// The peer, filled by the engine (empty inside one `GASPSync`).
627    #[serde(default)]
628    pub peer: String,
629    /// The root outpoint.
630    pub outpoint: String,
631    /// Its class, one of [`fault_class`].
632    pub class: String,
633    /// The consecutive faulted runs its record counts (0: not recorded).
634    pub faults: u32,
635    /// Whether it is recorded unanchorable (the cursor passes it).
636    pub unanchorable: bool,
637    /// The error, cut to 160 characters.
638    pub error: String,
639    /// Why its strike was refused: `no_room` at the storage's own ceiling,
640    /// or `store_fault`; empty when written or no strike was due.
641    #[serde(default, skip_serializing_if = "String::is_empty")]
642    pub refused: String,
643}
644
645/// Why a strike was refused (engine 0.1.10), as [`FaultedRoot::refused`]
646/// names it.
647pub mod strike_refused {
648    /// No room at the storage's own ceiling. Memo eviction is not a refusal.
649    pub const NO_ROOM: &str = "no_room";
650    /// The storage faulted writing the record.
651    pub const STORE_FAULT: &str = "store_fault";
652}
653
654/// The per-graph budget of a [`GASPSync`] (bsv-low #555,
655/// `Engine::set_graph_budget`). Setting one turns deferral ON for that sync.
656pub struct GraphBudget<'a> {
657    /// Calls one graph may make in one pass ([`DEFAULT_GRAPH_BUDGET_CALLS`]).
658    pub max_calls: u32,
659    /// Bytes one graph may be served in one pass
660    /// ([`DEFAULT_GRAPH_BUDGET_BYTES`], bsv-low #586). Reached, the graph is
661    /// DEFERRED as at `max_calls` (reason `bytes`): never dropped, never
662    /// refused.
663    pub max_bytes_fetched: u64,
664    /// Nodes one graph may append in one pass
665    /// ([`DEFAULT_GRAPH_BUDGET_NODES`], bsv-low #586). Reached, the graph is
666    /// deferred (reason `nodes`).
667    pub max_nodes: u32,
668    /// A fresh deadline for ONE graph's pass ([`DEFAULT_GRAPH_BUDGET_MS`]).
669    pub deadline: Box<dyn Fn() -> crate::engine::SleepFuture + 'a>,
670    /// A fresh deadline for HALF of it (the delta-2 fold's D2-L1): a FRESH
671    /// walk that faults once this fell due, or after half its calls, has
672    /// paid for a record and keeps one. `None`: the calls alone decide.
673    pub half_deadline: Option<Box<dyn Fn() -> crate::engine::SleepFuture + 'a>>,
674}
675
676impl GraphBudget<'_> {
677    /// What one pass of one graph may spend.
678    fn pass(&self) -> PassCap {
679        PassCap {
680            calls: self.max_calls,
681            bytes: self.max_bytes_fetched,
682            nodes: self.max_nodes,
683        }
684    }
685}
686
687/// What one pass of a graph's walk may still spend of the per-graph budget's
688/// three counted limbs (its time is a deadline): calls made, bytes served,
689/// nodes appended.
690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
691struct PassCap {
692    calls: u32,
693    bytes: u64,
694    nodes: u32,
695}
696
697impl PassCap {
698    /// No bound: a sync with no per-graph budget, or a walk that goes on
699    /// under the per-peer budget alone (L4).
700    const NONE: PassCap = PassCap {
701        calls: u32::MAX,
702        bytes: u64::MAX,
703        nodes: u32::MAX,
704    };
705
706    /// Whether a limb is spent to nothing.
707    fn spent(self) -> bool {
708        self.calls == 0 || self.bytes == 0 || self.nodes == 0
709    }
710}
711
712/// The key of one held record (bsv-low #555): what a sync loads up front.
713/// The records resume independently of the peer's listing, one at a time
714/// ([`GASPStorage::get_deferred_graph`]), so at most one record is in memory
715/// at a time (the lens fold's L3: the up-front load held up to 16 of them).
716#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
717#[serde(rename_all = "camelCase")]
718pub struct DeferredGraphKey {
719    /// The root UTXO, `txid.outputIndex`.
720    pub outpoint: String,
721    /// The root UTXO's score at the peer.
722    pub score: u64,
723}
724
725/// What a storage did with a record it was asked to save (bsv-low #555).
726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
727pub enum DeferredGraphSave {
728    /// Saved (replaced).
729    Saved,
730    /// Refused at a ceiling of the storage's own (the worker's global count
731    /// and byte bounds, the lens fold's M3): counted `too_many`, and the walk
732    /// goes on under the per-peer budget alone, as before #555. A BUDGET
733    /// signal, never the loss of a walk (bsv-low #585): the storage must
734    /// leave the record it HELD under that key as it was, and the engine
735    /// keeps it, so a resumed graph refused room resumes from that record at
736    /// the next pass and never walks from its root again.
737    AtCeiling,
738}
739
740/// One node a deferred walk has fetched and appended, in append order.
741#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
742#[serde(rename_all = "camelCase")]
743pub struct WalkedNode {
744    /// The node as it was appended (the root's proof hydrated, if it was).
745    pub node: GASPNode,
746    /// `txid.outputIndex` of the node that spends it; `None` for the root.
747    pub spent_by: Option<String>,
748}
749
750/// One input a deferred walk has still to ask for.
751#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
752#[serde(rename_all = "camelCase")]
753pub struct PendingInput {
754    /// `txid.outputIndex` of the input (for the root, the UTXO itself).
755    pub outpoint: String,
756    /// The graph id the request carries (the root's outpoint).
757    pub graph_id: String,
758    /// Whether the input's metadata is asked for.
759    pub metadata: bool,
760    /// `txid.outputIndex` of the node that needs it; `None` for the root.
761    pub spent_by: Option<String>,
762    /// Whether that node is PROVEN: a named input the peer does not hold is
763    /// then pruned (the D8 rule), else it fails the graph.
764    pub parent_proven: bool,
765}
766
767/// The persisted partial walk of ONE graph (bsv-low #555): one record per
768/// (peer, topic, root outpoint), REPLACED on every deferral, never appended.
769#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
770#[serde(rename_all = "camelCase")]
771pub struct DeferredGraph {
772    /// The peer the graph is walked from.
773    pub peer: String,
774    /// The topic.
775    pub topic: String,
776    /// The root UTXO, `txid.outputIndex`.
777    pub outpoint: String,
778    /// The root UTXO's score at the peer.
779    pub score: u64,
780    /// The nodes fetched so far, with their proofs, in append order.
781    pub nodes: Vec<WalkedNode>,
782    /// The inputs still to ask for, a stack (the last is asked next).
783    pub pending: Vec<PendingInput>,
784    /// Calls spent on this graph over every pass.
785    pub calls: u64,
786    /// Passes that deferred it (its age, in passes).
787    pub passes: u32,
788    /// Why it was last deferred: `calls`, `time`, `bytes`, `nodes`, `fault`,
789    /// `anchor_unavailable`, `anchor_walk_held` (the E592 lens fold: the
790    /// anchor's walk past the budget, held until the root is served proven),
791    /// `not_landed` or `peer_deadline`; for a root whose walk faulted
792    /// (engine 0.1.9) `retry:fault:<class>` under
793    /// [`UNANCHORABLE_AFTER_FAULTED_RUNS`] runs, `unanchorable:<class>` at it.
794    pub reason: String,
795    /// Whether the peer is a CONFIGURED one (`SyncTarget::Peers`), not one
796    /// `ls_ship` discovered (bsv-low #555, the delta-2 fold's D2-M2): a
797    /// storage reserves part of its ceiling for these (the worker: records
798    /// of discovered peers hold at most half of it). Set by the storage
799    /// adapter on every save; `false` in a record saved before the fold.
800    #[serde(default)]
801    pub configured: bool,
802    /// Consecutive RESUMED passes that faulted having appended no node
803    /// (the delta-2 fold's D2-M2): the record is dropped (`idle_faults`)
804    /// when it reaches [`DEFERRED_GRAPH_MAX_IDLE_FAULTS`]; a pass that
805    /// appends a node resets it.
806    #[serde(default)]
807    pub idle_faults: u32,
808    /// The cursor may pass this durable obligation. A false value also reads
809    /// records written before independent resume support.
810    #[serde(default)]
811    pub cursor_passable: bool,
812    /// Consecutive runs whose walk of this root faulted (engine 0.1.9): a
813    /// record `retry:fault:<class>` under [`UNANCHORABLE_AFTER_FAULTED_RUNS`],
814    /// `unanchorable:<class>` at it. 0 in every other record (and not
815    /// written, so their JSON is unchanged).
816    #[serde(default, skip_serializing_if = "is_zero_u32")]
817    pub faults: u32,
818    /// Unix seconds before which an unanchorable root is not walked again
819    /// ([`UNANCHORABLE_RETRY_SECS`]); 0 otherwise.
820    #[serde(default, skip_serializing_if = "is_zero_u64")]
821    pub retry_at: u64,
822    /// Consecutive passes that RESUMED this held record and appended no node
823    /// (engine 0.1.10): at [`IDLE_RESUMES_BEFORE_BACKOFF`] the record waits
824    /// [`IDLE_RESUME_RETRY_SECS`] (its `retry_at`) before its next resume; a
825    /// pass that appends a node resets it. 0 in every other record (and not
826    /// written, so their JSON is unchanged).
827    #[serde(default, skip_serializing_if = "is_zero_u32")]
828    pub idle_resumes: u32,
829    /// Retry reservations made after the unanchorable verdict, including cuts.
830    #[serde(default, skip_serializing_if = "is_zero_u32")]
831    pub unanchorable_retries: u32,
832    /// Advert expiry read by the topic manager from the content-addressed root.
833    #[serde(default, skip_serializing_if = "Option::is_none")]
834    pub expiry: Option<u64>,
835}
836
837// serde's `skip_serializing_if` passes the field by reference.
838#[allow(clippy::trivially_copy_pass_by_ref)]
839fn is_zero_u32(n: &u32) -> bool {
840    *n == 0
841}
842
843#[allow(clippy::trivially_copy_pass_by_ref)]
844fn is_zero_u64(n: &u64) -> bool {
845    *n == 0
846}
847
848impl DeferredGraph {
849    /// Whether this record counts a root's faulted runs (engine 0.1.9).
850    pub fn is_struck(&self) -> bool {
851        self.faults > 0
852            && (self.reason.starts_with(FAULT_RETRY_REASON)
853                || self.reason.starts_with(UNANCHORABLE_REASON)
854                || self.reason.starts_with(DEAD_REASON))
855    }
856
857    /// Whether this record holds an unanchorable root (passable).
858    pub fn is_unanchorable(&self) -> bool {
859        self.is_struck() && self.reason.starts_with(UNANCHORABLE_REASON)
860    }
861
862    pub fn is_dead(&self) -> bool {
863        self.is_struck() && self.reason.starts_with(DEAD_REASON)
864    }
865
866    /// Whether this held record waits out the long backoff of its idle
867    /// resumes at `now` (engine 0.1.10): not resumed, the cursor passes it.
868    /// A fault record keeps its own backoff, a record that holds the cursor
869    /// is never backed off.
870    pub fn waits_idle(&self, now: u64) -> bool {
871        !self.is_struck() && self.cursor_passable && now < self.retry_at
872    }
873
874    /// The fault class the record names, if it counts faulted runs.
875    pub fn fault_class(&self) -> Option<&str> {
876        if !self.is_struck() {
877            return None;
878        }
879        self.reason
880            .strip_prefix(DEAD_REASON)
881            .or_else(|| self.reason.strip_prefix(UNANCHORABLE_REASON))
882            .or_else(|| self.reason.strip_prefix(FAULT_RETRY_REASON))
883    }
884
885    /// The record's size as stored (its JSON): what the deferral's log line
886    /// and a storage's byte budget read. Nothing refuses a record for it
887    /// (bsv-low #585).
888    pub fn byte_size(&self) -> usize {
889        serde_json::to_vec(self).map_or(usize::MAX, |b| b.len())
890    }
891}
892
893/// Why a deferred graph's record was deleted without converging.
894#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
895#[serde(rename_all = "snake_case")]
896pub enum DropReason {
897    /// Deferred [`DEFERRED_GRAPH_MAX_PASSES`] times; the UTXO fails.
898    MaxPasses,
899    /// Past [`DEFERRED_GRAPHS_PER_PEER_TOPIC`] or the storage's ceiling: a
900    /// walk the budget cut goes on under the per-peer budget alone (L4),
901    /// else the UTXO fails. A RESUMED walk's held record is KEPT (bsv-low
902    /// #585): the count is of the pass that found no room, not of a record.
903    TooMany,
904    /// The record could not be written; as [`Self::TooMany`] (D-L2).
905    StoreFault,
906    /// A sync that ran to its end was not served the UTXO (spent at the peer).
907    NotServed,
908    /// The node already holds the UTXO (it landed another way).
909    Held,
910    /// The peer answered it does not hold an input an UNPROVEN node needs;
911    /// the UTXO fails, as in the reference.
912    NotHeld,
913    /// The root came back PROVEN: the walk restarts from it, shorter.
914    RootProven,
915    /// The anchor check refused the graph (a final verdict, the cursor moves).
916    Refused,
917    /// A resumed record whose pass ended still holding no node (a record
918    /// saved before the lens fold's H1, which saves none such); the UTXO
919    /// fails.
920    NoProgress,
921    /// [`DEFERRED_GRAPH_MAX_IDLE_FAULTS`] resumed passes in a row faulted
922    /// having appended no node (the delta-2 fold's D2-M2); the UTXO fails.
923    IdleFaults,
924}
925
926impl DropReason {
927    /// Every reason, for a caller that serves a counter per reason.
928    pub const ALL: [DropReason; 10] = [
929        Self::MaxPasses,
930        Self::TooMany,
931        Self::StoreFault,
932        Self::NotServed,
933        Self::Held,
934        Self::NotHeld,
935        Self::RootProven,
936        Self::Refused,
937        Self::NoProgress,
938        Self::IdleFaults,
939    ];
940
941    /// The reason's name, as logged and counted.
942    pub fn as_str(self) -> &'static str {
943        match self {
944            Self::MaxPasses => "max_passes",
945            Self::TooMany => "too_many",
946            Self::StoreFault => "store_fault",
947            Self::NotServed => "not_served",
948            Self::Held => "held",
949            Self::NotHeld => "not_held",
950            Self::RootProven => "root_proven",
951            Self::Refused => "refused",
952            Self::NoProgress => "no_progress",
953            Self::IdleFaults => "idle_faults",
954        }
955    }
956}
957
958/// One record dropped in a sync.
959#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
960pub struct DroppedGraph {
961    /// The root outpoint.
962    pub outpoint: String,
963    /// Why.
964    pub reason: DropReason,
965}
966
967/// What a sync did with deferred graphs (bsv-low #555).
968#[derive(Debug, Clone, Default, PartialEq)]
969pub struct DeferralStats {
970    /// Per-pass work limits that stopped a root.
971    pub root_bounds: Vec<crate::sync_load::RootBound>,
972    /// Graphs deferred (a new record, or a resumed one deferred again).
973    pub deferred: u64,
974    /// Graphs resumed from a record.
975    pub resumed: u64,
976    /// Resumed graphs that completed and landed (their record deleted).
977    pub converged: u64,
978    /// Partial walks dropped or reset without converging.
979    pub dropped: Vec<DroppedGraph>,
980    /// Walks of the sync that ended unfinished (deferred, or dropped at a
981    /// bound) having appended at least one node in their pass, or that
982    /// completed the graph (the lens fold's H1): the peer answered.
983    pub progressed: u64,
984    /// Walks of the sync that ended unfinished having appended NO node in
985    /// their pass (a hung or failing peer; the lens fold's H1). A sync whose
986    /// only outcome was these is a FAILED attempt for #302's quarantine.
987    pub stalled: u64,
988    /// Records NOT resumed this sync because the resumes of the pass had
989    /// spent their shared budget (the lens fold's M1); held, untouched, for
990    /// the next sync.
991    pub held_back: u64,
992    /// Records sent to the long backoff by this sync (engine 0.1.10): their
993    /// resume appended no node [`IDLE_RESUMES_BEFORE_BACKOFF`] passes in a
994    /// row.
995    pub backed_off: u64,
996    /// Records NOT resumed by this sync because they wait out that backoff
997    /// (no request at all; the cursor passes them).
998    pub waiting: u64,
999    /// Records whose resumed pass this sync counted one idle resume on
1000    /// (engine 0.1.11): it asked and appended nothing it kept, whatever its
1001    /// ending (a save, a drop, an error). A record a strike counted instead
1002    /// is not among them.
1003    pub idle_counted: u64,
1004}
1005
1006impl Walk {
1007    /// The counted limb this pass has spent, if any: its name is the
1008    /// deferral's reason.
1009    fn over(&self) -> Option<&'static str> {
1010        if self.calls_this_pass >= self.cap.calls {
1011            Some("calls")
1012        } else if self.bytes_this_pass >= self.cap.bytes {
1013            Some("bytes")
1014        } else if self.nodes_this_pass >= self.cap.nodes {
1015            Some("nodes")
1016        } else {
1017            None
1018        }
1019    }
1020}
1021
1022/// The walk of the graph in hand: its record (what a deferral saves) and
1023/// the per-pass state. Lives in the [`GASPSync`], OUTSIDE the walk's
1024/// future, so a deadline that drops the future leaves it whole.
1025struct Walk {
1026    record: DeferredGraph,
1027    seen: std::collections::HashSet<String>,
1028    calls_this_pass: u32,
1029    /// Bytes served to this pass (bsv-low #586; see
1030    /// [`DEFAULT_GRAPH_BUDGET_BYTES`] for what is counted).
1031    bytes_this_pass: u64,
1032    /// Nodes this pass appended (bsv-low #586).
1033    nodes_this_pass: u32,
1034    resumed: bool,
1035    /// What this pass may spend: the per-graph budget, or what the resumes
1036    /// of the pass have left of their shared one (M1); [`PassCap::NONE`]
1037    /// once a walk that cannot be kept goes on under the per-peer budget
1038    /// alone (L4).
1039    cap: PassCap,
1040    /// `record.nodes.len()` when the pass started (H1).
1041    nodes_at_start: usize,
1042    /// Whether this pass was already counted progressed or stalled.
1043    counted: bool,
1044    /// The walk could not be kept and GOES ON under the per-peer budget
1045    /// alone (L4): its drop is counted once, when it went on, and it is
1046    /// never saved again in this pass (the delta fold's D-L1: a per-peer
1047    /// deadline that then cut it counted the drop twice and asked the
1048    /// storage a second time).
1049    gone_on: bool,
1050    /// A FRESH walk's half-budget deadline, started with the walk (the
1051    /// delta-2 fold's D2-L1); `None` for a resumed walk or with no
1052    /// [`GraphBudget::half_deadline`].
1053    half: Option<crate::engine::SleepFuture>,
1054    /// The record of a root whose walk faulted in earlier runs (engine
1055    /// 0.1.9), walked again from its root: the walk holds that record's
1056    /// slot, a landing or a verdict deletes it, and a fault counts one more
1057    /// run on it ([`GASPSync::strike`]).
1058    struck: Option<Box<DeferredGraph>>,
1059}
1060
1061/// How a graph's walk ended in this pass.
1062enum WalkEnd {
1063    Expired,
1064    /// Nothing is pending: complete the graph.
1065    Done,
1066    /// Deferred, with this reason.
1067    Deferred(&'static str),
1068}
1069
1070/// What one step of the walk got.
1071enum StepOut {
1072    Expired,
1073    Pruned,
1074    Seen,
1075    Appended {
1076        node_id: String,
1077        walked: Box<WalkedNode>,
1078        children: Vec<PendingInput>,
1079    },
1080}
1081
1082/// How [`GASPSync::ingest_utxo`] ended for a UTXO.
1083enum Ingested {
1084    Retry,
1085    Expired,
1086    Completed,
1087    Deferred,
1088    /// A record not resumed: the pass's resumes spent their shared budget.
1089    HeldBack,
1090    /// An unanchorable root, held (engine 0.1.9): recorded at this walk, or
1091    /// a record whose backoff has not run out. The cursor passes it.
1092    Unanchorable,
1093    /// A held record that waits out the long backoff of its idle resumes
1094    /// (engine 0.1.10): not resumed, no request. The cursor passes it.
1095    Waiting,
1096}
1097
1098/// What a strike did (engine 0.1.10): the record that holds the count
1099/// (its faulted runs, whether it is unanchorable), and why it was refused.
1100#[derive(Debug, Clone, Copy, Default)]
1101struct Strike {
1102    held: Option<(u32, bool)>,
1103    refused: Option<&'static str>,
1104}
1105
1106impl Strike {
1107    /// Whether the root is held unanchorable: the cursor passes it.
1108    fn passable(self) -> bool {
1109        self.held.is_some_and(|(_, unanchorable)| unanchorable)
1110    }
1111}
1112
1113/// What [`GASPSync::save_walk`] did with the walk in hand.
1114enum Saved {
1115    Yes,
1116    Retry,
1117    /// Not kept, for this reason; the walk is handed back so the caller can
1118    /// go on with it (L4) or drop it.
1119    Unkept(DropReason, Box<Walk>),
1120    /// The storage faulted (counted `store_fault`, a held record deleted);
1121    /// the walk is handed back so the caller can go on with it (D-L2).
1122    Fault(GASPError, Box<Walk>),
1123    /// A RESUMED walk the storage had no room for (`AtCeiling`; counted
1124    /// `too_many`): the record it holds is KEPT as it was (bsv-low #585), and
1125    /// the walk is handed back so the caller can go on with it.
1126    Ceiling(Box<Walk>),
1127    /// The walk had already gone on (D-L1): nothing saved, nothing counted.
1128    WentOn,
1129}
1130
1131// ============================================================================
1132// GASPStorage trait
1133// ============================================================================
1134
1135/// Local storage interface for the GASP protocol.
1136///
1137/// Manages known UTXOs, temporary graph construction during sync,
1138/// and graph validation/finalization.
1139#[async_trait(?Send)]
1140pub trait GASPStorage {
1141    /// The root advert's own expiry. The adapter binds the bytes to the
1142    /// requested outpoint before interpreting a topic's expiry field.
1143    fn sync_expiry(&self, _node: &GASPNode, _outpoint: &str) -> Result<Option<u64>, GASPError> {
1144        Ok(None)
1145    }
1146
1147    /// Returns UTXOs known to be unspent since the given score/timestamp.
1148    /// Non-confirmed UTXOs should always be returned regardless of `since`.
1149    async fn find_known_utxos(
1150        &self,
1151        since: u64,
1152        limit: Option<u64>,
1153    ) -> Result<Vec<GASPOutput>, GASPError>;
1154
1155    /// Hydrate a GASP node with transaction data, proof, and optional metadata.
1156    async fn hydrate_gasp_node(
1157        &self,
1158        graph_id: &str,
1159        txid: &str,
1160        output_index: u32,
1161        metadata: bool,
1162    ) -> Result<GASPNode, GASPError>;
1163
1164    /// Determine which input transactions are needed to validate this node.
1165    /// Returns None if no additional inputs are needed.
1166    async fn find_needed_inputs(
1167        &self,
1168        node: &GASPNode,
1169    ) -> Result<Option<GASPNodeResponse>, GASPError>;
1170
1171    /// Append a node to a temporary graph being constructed during sync.
1172    /// `spent_by` is the "txid.outputIndex" of the node that spent this one (if not the root).
1173    async fn append_to_graph(
1174        &self,
1175        node: &GASPNode,
1176        spent_by: Option<&str>,
1177    ) -> Result<(), GASPError>;
1178
1179    /// Validate that the graph's anchor (root) references only proven or known transactions.
1180    async fn validate_graph_anchor(&self, graph_id: &str) -> Result<(), GASPError>;
1181
1182    /// Finalize a graph — commit the synced UTXO and its ancestors to permanent storage.
1183    async fn finalize_graph(&self, graph_id: &str) -> Result<(), GASPError>;
1184
1185    /// Discard a temporary graph that failed validation.
1186    async fn discard_graph(&self, graph_id: &str) -> Result<(), GASPError>;
1187
1188    /// The keys of the deferred graphs of this (peer, topic), lowest score
1189    /// first (bsv-low #555). REQUIRED, as the three below (the lens fold's
1190    /// M2): a storage that keeps no records says so here, by answering none
1191    /// and refusing every save, and its graphs past the budget then fail.
1192    async fn load_deferred_graphs(&self) -> Result<Vec<DeferredGraphKey>, GASPError>;
1193
1194    /// The record of the graph rooted at `outpoint`, if one is held.
1195    async fn get_deferred_graph(&self, outpoint: &str) -> Result<Option<DeferredGraph>, GASPError>;
1196
1197    /// Save (replace) the record of one deferred graph.
1198    async fn save_deferred_graph(
1199        &self,
1200        record: &DeferredGraph,
1201    ) -> Result<DeferredGraphSave, GASPError>;
1202
1203    /// Batched compact facts for a page, or due facts and maintenance with None.
1204    /// Standalone adapters may have no memo; engine adapters must forward it.
1205    async fn read_fault_memos(
1206        &self,
1207        _outpoints: Option<&[String]>,
1208    ) -> Result<crate::fault_memo::FaultMemoBatch, GASPError> {
1209        Ok(crate::fault_memo::FaultMemoBatch::default())
1210    }
1211
1212    /// Atomically claim the next retry. A storage without retry support declines.
1213    async fn reserve_unanchorable_retry(&self, _record: &DeferredGraph) -> Result<bool, GASPError> {
1214        Ok(false)
1215    }
1216
1217    /// Delete the record of the graph rooted at `outpoint`.
1218    async fn delete_deferred_graph(&self, outpoint: &str) -> Result<(), GASPError>;
1219
1220    /// Remember a root refused as expired at `expiry` (engine 0.1.8), so a
1221    /// later sync skips it with no node fetch. Default: nothing kept, the
1222    /// walk fetches and refuses it again (fail-safe).
1223    async fn remember_expired(&self, _outpoint: &str, _expiry: u64) -> Result<(), GASPError> {
1224        Ok(())
1225    }
1226
1227    /// Of `outpoints`, those remembered as expired. Default: none.
1228    async fn remembered_expired(&self, _outpoints: &[String]) -> Result<Vec<String>, GASPError> {
1229        Ok(Vec::new())
1230    }
1231
1232    /// A page of the peer's list begins (engine 0.2.2): its admissions are
1233    /// written in one batch at its end ([`Self::close_page`]). Default:
1234    /// nothing buffered, every write is made as it comes.
1235    fn open_page(&self) {}
1236
1237    /// The page ends: apply its writes. An `Err` admitted nothing of the
1238    /// writes still queued; the sync then fails the page. Default: `Ok`.
1239    async fn close_page(&self) -> Result<(), GASPError> {
1240        Ok(())
1241    }
1242
1243    /// Read at once what the page's admissions will ask the store: the
1244    /// applied state of `roots` (the page's fresh txids, as it opens) and
1245    /// the held inputs of `nodes` (a batch answer, once in hand). Default:
1246    /// nothing read ahead.
1247    async fn prefetch_page(&self, _roots: &[String], _nodes: &[&GASPNode]) {}
1248}
1249
1250// ============================================================================
1251// GASPRemote trait
1252// ============================================================================
1253
1254/// Communication interface with a foreign GASP peer.
1255#[async_trait(?Send)]
1256pub trait GASPRemote {
1257    /// Send an initial request and get the peer's initial response.
1258    async fn get_initial_response(
1259        &self,
1260        request: &GASPInitialRequest,
1261    ) -> Result<GASPInitialResponse, GASPError>;
1262
1263    /// Send our initial response and get the peer's reply.
1264    async fn get_initial_reply(
1265        &self,
1266        response: &GASPInitialResponse,
1267    ) -> Result<GASPInitialReply, GASPError>;
1268
1269    /// Request a specific node from the peer.
1270    async fn request_node(
1271        &self,
1272        graph_id: &str,
1273        txid: &str,
1274        output_index: u32,
1275        metadata: bool,
1276    ) -> Result<GASPNode, GASPError>;
1277
1278    /// Submit a node to the peer and get back which inputs they need.
1279    async fn submit_node(&self, node: &GASPNode) -> Result<Option<GASPNodeResponse>, GASPError>;
1280
1281    /// Whether this remote can ask the peer for many nodes in one body
1282    /// (engine 0.2.1, #58; [`crate::node_batch`]). `false` for a remote that
1283    /// has no batch request: the sync then asks one node at a time.
1284    fn batches(&self) -> bool {
1285        false
1286    }
1287
1288    /// Ask the peer for the nodes of `outpoints` in one body
1289    /// (`POST /requestForeignGASPNodes`). Any error, a 404 from a peer that
1290    /// does not answer the route included, makes the sync ask that peer one
1291    /// node at a time for the rest of the run.
1292    async fn request_nodes(
1293        &self,
1294        _outpoints: &[crate::node_batch::BatchOutpoint],
1295    ) -> Result<crate::node_batch::NodeBatch, GASPError> {
1296        Err(GASPError::Other(
1297            "this remote has no batch node request".into(),
1298        ))
1299    }
1300}
1301
1302// ============================================================================
1303// GASPRemoteFactory trait
1304// ============================================================================
1305
1306/// Factory for creating `GASPRemote` instances for specific peers.
1307///
1308/// The Engine holds an optional factory. Platform-specific crates (like
1309/// overlay-cloudflare) provide an implementation that creates HTTP-based
1310/// remotes.
1311pub trait GASPRemoteFactory {
1312    /// Create a `GASPRemote` for the given peer URL and topic.
1313    fn create_remote(&self, peer_url: &str, topic: &str) -> Box<dyn GASPRemote>;
1314}
1315
1316// ============================================================================
1317// AncestorFetcher trait (OPT-IN, OFF by default)
1318// ============================================================================
1319
1320/// Optional chain-backed ancestor fetcher for GASP ingest self-healing.
1321///
1322/// **OPT-IN / OFF BY DEFAULT.** When a peer (e.g. legacy TS beta) cannot serve
1323/// a needed ancestor node during graph ingest (it returns HTTP 400 "Incomplete
1324/// SPV data!" because its stored BEEF is minimal), the orchestrator normally
1325/// abandons that graph. If — and ONLY if — an `AncestorFetcher` is configured,
1326/// `process_incoming_node` falls back to fetching the ancestor's raw tx from
1327/// chain (e.g. WhatsOnChain) and synthesizing a no-proof `GASPNode` that the
1328/// existing recursion stitches into the graph.
1329///
1330/// This is a deliberate one-time-migration escape hatch. Production must NOT
1331/// configure a fetcher: when the fetcher is `None` the ingest path is
1332/// byte-identical to today (peer errors propagate / are swallowed upstream).
1333///
1334/// The orchestrator stays platform-agnostic; the concrete WoC/`worker::Fetch`
1335/// implementation lives in the platform crate (e.g. zanaadu `overlay`), never
1336/// in this engine crate (no `reqwest`/`std::time` — must stay wasm-clean).
1337#[async_trait(?Send)]
1338pub trait AncestorFetcher {
1339    /// Reset invocation-local retry caches before a new sync.
1340    fn begin_sync(&self) {}
1341
1342    /// Fetch an ancestor transaction by txid from chain.
1343    ///
1344    /// Returns the raw tx hex plus, when the ancestor is mined, its BUMP merkle
1345    /// proof hex. A proven node ends the walk unless its topic manager names
1346    /// inputs needed for overlay history. Managers naming nothing still stop
1347    /// at the first proven layer. Managers needing history select those inputs
1348    /// instead of walking every funding/fee input back toward coinbase.
1349    ///
1350    /// The transaction DAG (no cycles), `seen` keyed by `txid.outputIndex`,
1351    /// the already-known strip, and the manager naming nothing at genesis
1352    /// bound the walk. There is no node cap, matching the reference's undefined
1353    /// `maxNodesInGraph` in `Engine.startGASPSync`. A per-peer budget drops the
1354    /// sync future at its deadline: the graphs finalized before it stay
1355    /// admitted (bsv-low #552, [`FinalizedGraphHook`]), the graph in flight
1356    /// is lost whole and walked again by the next sync, unless a per-graph
1357    /// budget is set (bsv-low #555, `Engine::set_graph_budget`): then a walk
1358    /// past either budget is deferred with its fetched nodes kept and resumed
1359    /// by the next sync, so ONE graph deeper than a pass converges over
1360    /// passes. Without it such a graph still never completes.
1361    ///
1362    /// The implementation MUST verify that the returned bytes hash to the
1363    /// requested `txid` before returning them (integrity check) so a
1364    /// malicious/garbled response cannot inject a forged ancestor.
1365    async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError>;
1366
1367    /// Fetch (and chaintracks-verify) ONLY the merkle BUMP for `txid`, WITHOUT
1368    /// fetching the raw tx.
1369    ///
1370    /// The proof-completion passes (`complete_missing_proofs`, the LOW pot-store
1371    /// tick) already hold the raw in the stored BEEF, so the raw fetch that
1372    /// [`Self::fetch_ancestor`] performs is a redundant network round-trip there
1373    /// — and a free-tier WhatsOnChain raw fetch 429s (#192/#193). This method is
1374    /// the raw-free path.
1375    ///
1376    /// Default: delegate to `fetch_ancestor` and drop the raw, so a fetcher that
1377    /// only implements `fetch_ancestor` keeps working unchanged. The production
1378    /// `ChainProofFetcher` overrides it to skip the raw fetch entirely. Returns
1379    /// `None` for an unmined/unverifiable tx (fail-closed), never an error.
1380    async fn verified_proof_for(&self, txid: &str) -> Option<String> {
1381        self.fetch_ancestor(txid).await.ok().and_then(|a| a.proof)
1382    }
1383
1384    /// Like [`Self::verified_proof_for`], but a TRANSPORT/READ FAULT stays
1385    /// distinguishable (bsv-low#304 gate M-5): `Err` = the proof source or
1386    /// header source READ failed (e.g. a chaintracks call starved at the
1387    /// invocation's subrequest wall) — retryable, NOT a chain verdict;
1388    /// `Ok(None)` = honestly no verified proof yet. Money-relevant callers
1389    /// (the spend-confirmation chaser) surface + count the fault so a
1390    /// starved tick is visible instead of masquerading as "not mined yet".
1391    /// Default: wrap `verified_proof_for` (which never faults) — existing
1392    /// fetchers keep their semantics unchanged.
1393    async fn verified_proof_for_detailed(&self, txid: &str) -> Result<Option<String>, String> {
1394        Ok(self.verified_proof_for(txid).await)
1395    }
1396
1397    /// bsv-low #451 slice B (2026-09-17): like [`Self::verified_proof_for_detailed`], but the ladder asks EVERY
1398    /// rung — no per-pass short-circuit on the broadcaster's "held unmined" word. The completion passes may stop
1399    /// early (a fresh unmined word from Arcade means no courier holds a proof yet; the next tick asks again), but
1400    /// a caller that ACTS on `Ok(None)` this pass — the reorg re-anchor, which demotes a refuted row it could not
1401    /// re-prove; the unmined-ancestry walk, which treats an unproven ancestor as a branch to keep walking — must
1402    /// hear the couriers too (the delta-verify's LOW-A). Default: the detailed ask (a fetcher with no short-circuit
1403    /// has nothing to skip).
1404    async fn verified_proof_for_exhaustive(&self, txid: &str) -> Result<Option<String>, String> {
1405        self.verified_proof_for_detailed(txid).await
1406    }
1407
1408    /// bsv-low M19B-G1 round 2 (review MED-2): the ladder's REMAINING
1409    /// per-invocation budget, when it keeps one (`None` = unbounded, or not
1410    /// known). A budgeted ladder answers `Ok(None)` to every ask once its
1411    /// budget is spent, which a caller must not read as "no courier serves a
1412    /// proof": it checks this first and leaves the row for a fresh budget.
1413    fn budget_remaining(&self) -> Option<u32> {
1414        None
1415    }
1416
1417    /// Verify that `bump_hex` is a chaintracks-valid merkle proof for `txid`.
1418    ///
1419    /// Used by proof completion to re-check a STORED structural bump before
1420    /// trusting its `has_proof` flag: a structural bump admitted WITHOUT SPV (or
1421    /// forged) must never be latched-proven and trimmed on (#192/#193). Default:
1422    /// fail-closed `false` — a fetcher with no header source can prove nothing.
1423    /// The production `ChainProofFetcher` overrides it against chaintracks.
1424    async fn verify_proof(&self, txid: &str, bump_hex: &str) -> bool {
1425        let _ = (txid, bump_hex);
1426        false
1427    }
1428
1429    /// Which txid spends `txid:vout`, per an EXTERNAL INDEXER — a HINT, never
1430    /// a verdict (bsv-low 2026-08-18, the displaced-spender reconcile). The
1431    /// caller must independently prove the hint before acting on it:
1432    /// [`Self::spender_binding_raw`] (the bytes really spend the outpoint)
1433    /// and [`Self::verified_proof_for_detailed`] (the spender is MINED, root
1434    /// verified against our PoW-anchored headers). An indexer can therefore
1435    /// only ever point at the REAL confirmed spender — pointing anywhere else
1436    /// fails one of the two proofs and changes nothing.
1437    ///
1438    /// Default `Ok(None)` — unknown — so every existing implementation keeps
1439    /// compiling AND keeps today's behavior (no resolution, fail-closed).
1440    async fn resolve_spender(&self, _txid: &str, _vout: u32) -> Result<Option<String>, String> {
1441        Ok(None)
1442    }
1443
1444    /// bsv-low (2026-09-04) — the SCRIPTHASH-HISTORY rung: the txids a
1445    /// courier's script-history index lists for `scripthash_le_hex` OTHER
1446    /// than `funding_txid` (newest first, a few at most). A pot's covenant
1447    /// script is unique to that pot, so its history is exactly
1448    /// `[funding, spender]` and the answer is the spender CANDIDATE; the
1449    /// caller BINDS it with [`spender_binding_raw`] before believing it. This
1450    /// rung survives a provider retiring its per-output spend endpoint
1451    /// (Bitails, pruned mode, 2026-09). `Err` = every rung faulted; an empty
1452    /// `Ok` = the indexes answered and list no other tx. Default: no index.
1453    ///
1454    /// [`spender_binding_raw`]: AncestorFetcher::spender_binding_raw
1455    async fn resolve_spender_by_script(
1456        &self,
1457        _scripthash_le_hex: &str,
1458        _funding_txid: &str,
1459    ) -> Result<Vec<String>, String> {
1460        Ok(Vec::new())
1461    }
1462
1463    /// bsv-low (2026-09-04): an output's LOCKING SCRIPT by outpoint from a
1464    /// courier's output index (BananaBlocks `/txo/{txid}/{vout}`) — the
1465    /// scripthash rung's script source when no BEEF is stored for the row.
1466    /// `Ok(None)` = the index never saw it. Default: no such index.
1467    async fn output_script_hint(&self, _txid: &str, _vout: u32) -> Result<Option<Vec<u8>>, String> {
1468        Ok(None)
1469    }
1470
1471    /// `spender`'s raw tx hex — fetched CONTENT-ADDRESSED (the bytes must
1472    /// hash to `spender`) — returned iff those bytes carry an input spending
1473    /// exactly `txid:vout`. This is the binding that turns
1474    /// [`Self::resolve_spender`]'s hint into a fact, and the caller gets the
1475    /// PROVEN bytes back so it can derive further facts from them (bytes
1476    /// finality, a durable BEEF) without a second fetch. `Ok(None)` = the
1477    /// bytes were read and do NOT bind (a lying or garbled hint — refuse);
1478    /// `Err` = could not read (a fault, never a verdict). Default `Ok(None)`:
1479    /// fail-closed, an unbindable hint is a dead hint.
1480    async fn spender_binding_raw(
1481        &self,
1482        _spender: &str,
1483        _txid: &str,
1484        _vout: u32,
1485    ) -> Result<Option<String>, String> {
1486        Ok(None)
1487    }
1488}
1489
1490/// An ancestor transaction fetched from chain: its raw tx hex plus an optional
1491/// BUMP merkle proof hex (present when the tx is mined).
1492#[derive(Debug, Clone)]
1493pub struct FetchedAncestor {
1494    /// Raw transaction hex.
1495    pub raw_tx: String,
1496    /// BUMP merkle proof hex, if the ancestor is mined. When `Some`, the
1497    /// synthesized node ends the walk unless its topic manager names inputs.
1498    pub proof: Option<String>,
1499}
1500
1501// ============================================================================
1502// FinalizedGraphHook (bsv-low #552)
1503// ============================================================================
1504
1505/// Called by [`GASPSync::sync`] after EVERY incoming UTXO whose graph was
1506/// completed (finalized, or refused by the anchor check and discarded), before
1507/// the next UTXO is asked for.
1508///
1509/// The reference submits inside `finalizeGraph`, so a graph is admitted the
1510/// moment it is finalized. Here `OverlayGASPStorage::finalize_graph` only
1511/// pushes to a sink (it holds a `Storage`, not the engine), and the engine
1512/// used to drain that sink after the WHOLE sync: a sync dropped at its
1513/// per-peer budget admitted nothing. With a hook the engine drains the sink
1514/// per graph, inside the raced future, so what was finalized before the
1515/// deadline stays admitted and the next sync's known-UTXO skip and known-input
1516/// strip resume from it.
1517///
1518/// An `Err` FAILS THAT UTXO, exactly as a failed ingest does: the gap guard
1519/// keeps the cursor below it and the next sync asks for it again. The engine
1520/// answers `Err` when a finalize submit did not land (the lens fold's
1521/// MEDIUM-1). It cannot fail the sync. While the hook runs,
1522/// [`GASPSync::completed_cursor`] still sits BELOW the UTXO just completed,
1523/// so a sync dropped inside the hook asks for that UTXO again.
1524#[async_trait(?Send)]
1525pub trait FinalizedGraphHook {
1526    /// One incoming UTXO's graph was completed; drain what it finalized.
1527    async fn graph_completed(&self) -> Result<(), GASPError>;
1528}
1529
1530// ============================================================================
1531// GASP orchestrator
1532// ============================================================================
1533
1534#[derive(Default)]
1535struct PageProgress {
1536    pages: u64,
1537    advanced: bool,
1538    reached_tip: bool,
1539    /// The cursor safe before any admission of the open page (engine
1540    /// 0.2.2): while the page's writes are queued, nothing above it is
1541    /// written yet.
1542    floor: Option<u64>,
1543}
1544
1545/// The node batch's state in one sync (engine 0.2.1, #58).
1546#[derive(Default)]
1547struct BatchState {
1548    /// The peer asked one node at a time for the rest of the run: its batch
1549    /// request failed or its answer was refused. Shared across the run's
1550    /// topics and slices, never persisted.
1551    single_only: std::rc::Rc<std::cell::Cell<bool>>,
1552    /// The page's outputs a batch may ask for, in page order.
1553    queue: std::cell::RefCell<std::collections::VecDeque<String>>,
1554    /// The batch answers not yet walked, each as its single request would
1555    /// have answered.
1556    prefetched: std::cell::RefCell<std::collections::HashMap<String, Result<GASPNode, GASPError>>>,
1557}
1558
1559/// GASP sync orchestrator.
1560///
1561/// Coordinates between local `GASPStorage` and a `GASPRemote` peer to
1562/// synchronize overlay UTXOs. Supports paginated sync and unidirectional mode.
1563///
1564/// The lifetime `'a` allows the storage and remote to borrow from their
1565/// environment (e.g., `OverlayGASPStorage` borrows from the Engine's storage).
1566pub struct GASPSync<'a> {
1567    expired: std::rc::Rc<std::cell::RefCell<std::collections::HashSet<String>>>,
1568    /// The node batch's state (engine 0.2.1, #58), boxed so the sync's
1569    /// future does not grow by it.
1570    batch: Box<BatchState>,
1571    root_proofs: std::cell::RefCell<std::collections::HashMap<String, bool>>,
1572    roots_proven: std::cell::Cell<u64>,
1573    roots_unproven: std::cell::Cell<u64>,
1574    cursor_held_by: Option<(u64, String)>,
1575    cursor_after_in_flight: u64,
1576    /// The root (score, outpoint) of the graph in flight, kept OUTSIDE the
1577    /// walk's future like [`Walk`], so a per-peer deadline that drops the
1578    /// sync before the walk exists (or after a refusal took it) still names
1579    /// the graph it cut.
1580    in_flight: Option<(u64, String)>,
1581    /// New durable holds this sync saved: see [`Self::graphs_retained`].
1582    retained: std::cell::Cell<u64>,
1583    /// Held graphs this sync resumed that appended a node or converged.
1584    resumed_with_progress: std::cell::Cell<u64>,
1585    cursor_yielded: std::cell::Cell<bool>,
1586    expired_refused: std::cell::Cell<u64>,
1587    /// The expiry `root_expired` last read off a refused root, for the
1588    /// durable memo.
1589    refused_expiry: std::cell::Cell<Option<u64>>,
1590    storage: Box<dyn GASPStorage + 'a>,
1591    remote: Box<dyn GASPRemote + 'a>,
1592    /// Score of last successful interaction with this peer.
1593    pub last_interaction: u64,
1594    /// If true, only pull from remote — don't push local UTXOs.
1595    pub unidirectional: bool,
1596    log_prefix: String,
1597    /// OPT-IN / OFF BY DEFAULT chain-backed ancestor fetcher. When `None`
1598    /// (the default), a peer's `request_node` error propagates and the graph
1599    /// is abandoned upstream, except for an input NAMED by a proven node's
1600    /// topic manager that the peer answers it does not hold
1601    /// (`GASPError::NodeNotFound`), whose branch is pruned (the D8 decoy
1602    /// rule). When `Some`, ancestors come from chain instead of the peer and
1603    /// nothing is pruned: every fetcher error abandons the graph.
1604    ancestor_fetcher: Option<std::rc::Rc<dyn AncestorFetcher + 'a>>,
1605    /// Manager-named inputs whose branch was PRUNED because the peer answered
1606    /// that it does not hold them (zanaadu-v2 #314, D8), keyed
1607    /// `txid.outputIndex`. One entry is one failed round trip: an outpoint in
1608    /// this set is never asked for again in this sync, whichever parent or
1609    /// graph names it. Cleared at the start of each `sync`.
1610    pruned: std::cell::RefCell<std::collections::HashSet<String>>,
1611    /// Distinct outpoints pruned in the last `sync` (the size of `pruned`).
1612    pruned_inputs: std::cell::Cell<u64>,
1613    /// Inputs of UNPROVEN nodes the peer answered it does not hold in this
1614    /// sync (engine 0.1.15), keyed `txid.outputIndex`: needed again by
1615    /// another graph of the sync, the answer is remembered, not asked.
1616    /// Cleared at the start of each `sync`.
1617    absent: std::cell::RefCell<std::collections::HashSet<String>>,
1618    /// Graphs REFUSED by `validate_graph_anchor` and discarded in the last
1619    /// `sync` (bsv-low #551). Cleared at the start of each `sync`.
1620    discarded_graphs: std::cell::Cell<u64>,
1621    /// Graphs whose anchor walk could not run within the budget and were
1622    /// HELD (the E592 lens fold): [`GASPSync::anchor_walk_held`].
1623    anchor_walk_held: std::cell::Cell<u64>,
1624    /// Called after every completed incoming UTXO (bsv-low #552). `None`
1625    /// (the default): nothing is called and `sync` runs as it always did.
1626    finalized_hook: Option<Box<dyn FinalizedGraphHook + 'a>>,
1627    /// See [`Self::completed_cursor`].
1628    completed_cursor: u64,
1629    /// See [`Self::graph_in_flight`].
1630    graph_in_flight: bool,
1631    /// The per-graph budget (bsv-low #555). `None` (the default): no graph
1632    /// is deferred and the walk is the one before #555.
1633    graph_budget: Option<GraphBudget<'a>>,
1634    /// The roots of this (peer, topic)'s records not yet resumed in this
1635    /// sync (keys only; records are read one at a time).
1636    deferred: std::cell::RefCell<std::collections::HashSet<String>>,
1637    /// ONE deadline for every resume of the sync (the lens fold's M1), made
1638    /// at the first resume.
1639    resume_deadline: std::cell::RefCell<Option<crate::engine::SleepFuture>>,
1640    /// What every resume of the sync may still spend together (M1).
1641    resume_left: std::cell::Cell<PassCap>,
1642    /// A fresh deadline for the resumes' share of a pass (engine 0.1.11,
1643    /// [`Self::with_resume_share`]); `None`: the per-graph deadline, made at
1644    /// the resume that opens the pass, as before.
1645    resume_share: Option<Box<dyn Fn() -> crate::engine::SleepFuture + 'a>>,
1646    /// Calls made by the sync's walks so far.
1647    calls_made: std::cell::Cell<u64>,
1648    /// Bytes served to the sync's walks so far (bsv-low #586).
1649    bytes_fetched: std::cell::Cell<u64>,
1650    /// Nodes appended by the sync's walks so far (bsv-low #586).
1651    nodes_appended: std::cell::Cell<u64>,
1652    /// How many records this (peer, topic) holds in storage, its fault
1653    /// records apart (engine 0.1.10: they are counted in `fault_records`).
1654    held_records: std::cell::Cell<usize>,
1655    /// The roots of this (peer, topic)'s FAULT records known in this sync
1656    /// (engine 0.1.14), outside the partial-graph slot count: each
1657    /// held one is known when the sync reads it (every held record is read
1658    /// ahead of the pages), a new one when its strike is written.
1659    fault_records: std::cell::RefCell<std::collections::HashSet<String>>,
1660    /// See [`Self::strikes_refused`].
1661    strikes_refused: std::cell::Cell<u64>,
1662    /// The idle resumes counted on the held record the resume in hand read
1663    /// (engine 0.1.10), and the nodes it held (engine 0.1.11), before its
1664    /// pass: what its pass adds one to when it asks and keeps nothing more.
1665    /// `None` for any other walk.
1666    resume_idle: std::cell::Cell<Option<(u32, usize)>>,
1667    /// The pass in hand saved its own record ([`Self::save_walk`]): that save
1668    /// counted its idle resume, or reset the count on a node appended, so
1669    /// nothing is counted after it.
1670    pass_saved: std::cell::Cell<bool>,
1671    /// The walk of the graph in hand (see [`Walk`]).
1672    walk: std::cell::RefCell<Option<Walk>>,
1673    /// See [`Self::deferral_stats`].
1674    stats: std::cell::RefCell<DeferralStats>,
1675    /// See [`Self::graphs_attempted`].
1676    graphs_attempted: std::cell::Cell<u64>,
1677    /// The fault class of the step that last failed a walk (engine 0.1.9),
1678    /// set where the walk faults and read where its root is dropped;
1679    /// `None` for a failure that is no fault of the graph (a storage fault
1680    /// of ours, a bound).
1681    last_fault: std::cell::Cell<Option<&'static str>>,
1682    /// See [`Self::faulted_roots`].
1683    faulted: std::cell::RefCell<Vec<FaultedRoot>>,
1684    /// See [`Self::unanchorable_recorded`].
1685    unanchorable_recorded: std::cell::Cell<u64>,
1686    /// See [`Self::unanchorable_cleared`].
1687    unanchorable_cleared: std::cell::Cell<u64>,
1688    /// See [`Self::absent_struck`].
1689    absent_struck: std::cell::Cell<u64>,
1690    dead_recorded: std::cell::Cell<u64>,
1691    expired_swept: std::cell::Cell<u64>,
1692    memo_folded: std::cell::Cell<u64>,
1693    root_expiries: std::cell::RefCell<std::collections::HashMap<String, u64>>,
1694    page: PageProgress,
1695    request_stop: Option<std::rc::Rc<dyn Fn() -> bool + 'a>>,
1696}
1697
1698impl<'a> GASPSync<'a> {
1699    /// Create a new GASP sync orchestrator.
1700    ///
1701    /// The ancestor fetcher is OFF by default. Use `with_ancestor_fetcher` to
1702    /// opt in to chain-backed ancestry hydration (one-time-migration only).
1703    pub fn new(
1704        storage: Box<dyn GASPStorage + 'a>,
1705        remote: Box<dyn GASPRemote + 'a>,
1706        last_interaction: u64,
1707        log_prefix: impl Into<String>,
1708        unidirectional: bool,
1709    ) -> Self {
1710        Self {
1711            storage,
1712            remote,
1713            expired: std::rc::Rc::default(),
1714            batch: Box::default(),
1715            expired_refused: std::cell::Cell::new(0),
1716            refused_expiry: std::cell::Cell::new(None),
1717            root_proofs: std::cell::RefCell::default(),
1718            roots_proven: std::cell::Cell::new(0),
1719            roots_unproven: std::cell::Cell::new(0),
1720            cursor_held_by: None,
1721            cursor_after_in_flight: last_interaction,
1722            in_flight: None,
1723            retained: std::cell::Cell::new(0),
1724            resumed_with_progress: std::cell::Cell::new(0),
1725            cursor_yielded: std::cell::Cell::new(false),
1726            last_interaction,
1727            unidirectional,
1728            log_prefix: log_prefix.into(),
1729            ancestor_fetcher: None,
1730            pruned: std::cell::RefCell::new(std::collections::HashSet::new()),
1731            pruned_inputs: std::cell::Cell::new(0),
1732            absent: std::cell::RefCell::default(),
1733            discarded_graphs: std::cell::Cell::new(0),
1734            anchor_walk_held: std::cell::Cell::new(0),
1735            finalized_hook: None,
1736            completed_cursor: last_interaction,
1737            graph_in_flight: false,
1738            graph_budget: None,
1739            deferred: std::cell::RefCell::new(std::collections::HashSet::new()),
1740            resume_deadline: std::cell::RefCell::new(None),
1741            resume_left: std::cell::Cell::new(PassCap::NONE),
1742            resume_share: None,
1743            calls_made: std::cell::Cell::new(0),
1744            bytes_fetched: std::cell::Cell::new(0),
1745            nodes_appended: std::cell::Cell::new(0),
1746            held_records: std::cell::Cell::new(0),
1747            fault_records: std::cell::RefCell::default(),
1748            strikes_refused: std::cell::Cell::new(0),
1749            resume_idle: std::cell::Cell::new(None),
1750            pass_saved: std::cell::Cell::new(false),
1751            walk: std::cell::RefCell::new(None),
1752            stats: std::cell::RefCell::new(DeferralStats::default()),
1753            graphs_attempted: std::cell::Cell::new(0),
1754            last_fault: std::cell::Cell::new(None),
1755            faulted: std::cell::RefCell::default(),
1756            unanchorable_recorded: std::cell::Cell::new(0),
1757            unanchorable_cleared: std::cell::Cell::new(0),
1758            absent_struck: std::cell::Cell::new(0),
1759            dead_recorded: std::cell::Cell::new(0),
1760            expired_swept: std::cell::Cell::new(0),
1761            memo_folded: std::cell::Cell::new(0),
1762            root_expiries: std::cell::RefCell::default(),
1763            page: PageProgress::default(),
1764            request_stop: None,
1765        }
1766    }
1767
1768    /// Share expired outpoints across the topic peers of one engine run.
1769    #[must_use]
1770    pub fn with_expired_outpoints(
1771        mut self,
1772        expired: std::rc::Rc<std::cell::RefCell<std::collections::HashSet<String>>>,
1773    ) -> Self {
1774        self.expired = expired;
1775        self
1776    }
1777
1778    /// Share the run's single-only memo of this peer (engine 0.2.1, #58).
1779    #[must_use]
1780    pub fn with_batch_memo(mut self, single_only: std::rc::Rc<std::cell::Cell<bool>>) -> Self {
1781        self.batch.single_only = single_only;
1782        self
1783    }
1784
1785    /// Backfill slices stop after one page or at a caller-owned request bound.
1786    pub fn with_page_slice(mut self, stop: std::rc::Rc<dyn Fn() -> bool + 'a>) -> Self {
1787        self.request_stop = Some(stop);
1788        self
1789    }
1790    fn request_stopped(&self) -> bool {
1791        self.request_stop.as_ref().is_some_and(|stop| stop())
1792    }
1793    pub fn pages(&self) -> u64 {
1794        self.page.pages
1795    }
1796    pub fn page_advanced(&self) -> bool {
1797        self.page.advanced
1798    }
1799    pub fn reached_tip(&self) -> bool {
1800        self.page.reached_tip
1801    }
1802
1803    pub fn expired_refused(&self) -> u64 {
1804        self.expired_refused.get()
1805    }
1806
1807    pub fn roots_proven(&self) -> u64 {
1808        self.roots_proven.get()
1809    }
1810    pub fn roots_unproven(&self) -> u64 {
1811        self.roots_unproven.get()
1812    }
1813    pub fn cursor_held_by(&self) -> Option<String> {
1814        self.cursor_held_by
1815            .as_ref()
1816            .map(|(_, outpoint)| outpoint.clone())
1817    }
1818
1819    pub fn cursor_yielded(&self) -> bool {
1820        self.cursor_yielded.get()
1821    }
1822
1823    /// New durable holds the last `sync` saved holding at least one node
1824    /// the peer served (a walk past its budget, or the graph in flight at
1825    /// the per-peer deadline): work the cursor may pass because the next
1826    /// sync resumes it. A hold of nothing is not counted: the peer served
1827    /// no node of it (the hung peer of bsv-low #555's H1).
1828    pub fn graphs_retained(&self) -> u64 {
1829        self.retained.get()
1830    }
1831
1832    /// Whether the last `sync` did work beyond finalizing a graph or moving
1833    /// the cursor (engine 0.1.8): it refused an expired root not refused
1834    /// before, retained a graph or resumed one with progress. Such a sync is
1835    /// never a failed attempt. Only the refusal also ends the yieldless
1836    /// streak (see `Engine::start_gasp_sync`).
1837    pub fn did_work(&self) -> bool {
1838        self.expired_refused.get() > 0
1839            || self.retained.get() > 0
1840            || self.resumed_with_progress.get() > 0
1841    }
1842
1843    /// Held graphs the last `sync` resumed that appended at least one node
1844    /// or converged.
1845    pub fn graphs_resumed_with_progress(&self) -> u64 {
1846        self.resumed_with_progress.get()
1847    }
1848
1849    /// The roots the last `sync` dropped, each named once with its fault
1850    /// class (engine 0.1.9): what `cursor_held_by` cannot say.
1851    pub fn faulted_roots(&self) -> Vec<FaultedRoot> {
1852        self.faulted.borrow().clone()
1853    }
1854
1855    /// Roots the last `sync` recorded unanchorable: their walk faulted in
1856    /// [`UNANCHORABLE_AFTER_FAULTED_RUNS`] consecutive runs, and the cursor
1857    /// passes them from now on.
1858    pub fn unanchorable_recorded(&self) -> u64 {
1859        self.unanchorable_recorded.get()
1860    }
1861
1862    /// Roots the last `sync` struck `unanchorable:not_found` (engine 0.1.15):
1863    /// the peers' word of absence counted on [`UNANCHORABLE_AFTER_FAULTED_RUNS`]
1864    /// runs at least [`ABSENCE_GAP_SECS`] apart. Counted in
1865    /// [`Self::unanchorable_recorded`] too.
1866    pub fn absent_struck(&self) -> u64 {
1867        self.absent_struck.get()
1868    }
1869
1870    /// Roots with a fault record (unanchorable or not yet) that the last
1871    /// `sync` walked again and admitted or refused on a verdict: their
1872    /// record cleared.
1873    pub fn dead_recorded(&self) -> u64 {
1874        self.dead_recorded.get()
1875    }
1876
1877    pub fn memo_folded(&self) -> u64 {
1878        self.memo_folded.get()
1879    }
1880
1881    pub fn expired_swept(&self) -> u64 {
1882        self.expired_swept.get()
1883    }
1884
1885    pub fn unanchorable_cleared(&self) -> u64 {
1886        self.unanchorable_cleared.get()
1887    }
1888
1889    /// Strikes the last `sync` could not write at the storage's ceiling
1890    /// or on a storage fault. Each is named in
1891    /// [`Self::faulted_roots`] with its [`FaultedRoot::refused`]; a refused
1892    /// strike of a new root leaves it a gap the cursor holds below.
1893    pub fn strikes_refused(&self) -> u64 {
1894        self.strikes_refused.get()
1895    }
1896
1897    /// Know `outpoint` as a fault record of this (peer, topic) (engine
1898    /// 0.1.10): one counted among the deferred slots moves to the fault
1899    /// records. A no-op for one already known.
1900    fn know_fault_record(&self, outpoint: &str) {
1901        if self.fault_records.borrow_mut().insert(outpoint.to_string()) {
1902            self.held_records
1903                .set(self.held_records.get().saturating_sub(1));
1904        }
1905    }
1906
1907    /// A record of this (peer, topic) read from the storage: a fault record
1908    /// is known as one (engine 0.1.10).
1909    fn read_record(&self, record: Option<&DeferredGraph>) {
1910        if let Some(record) = record.filter(|r| r.is_struck()) {
1911            self.know_fault_record(&record.outpoint);
1912        }
1913    }
1914
1915    /// A record of this (peer, topic) deleted: one fewer fault record, or
1916    /// one fewer deferred slot held.
1917    fn forget_record(&self, outpoint: &str) {
1918        if !self.fault_records.borrow_mut().remove(outpoint) {
1919            self.held_records
1920                .set(self.held_records.get().saturating_sub(1));
1921        }
1922    }
1923
1924    /// Release the cursor's hold on `outpoint`: it is finished or held.
1925    fn release_hold(&mut self, outpoint: &str) {
1926        if self
1927            .cursor_held_by
1928            .as_ref()
1929            .is_some_and(|(_, held)| held == outpoint)
1930        {
1931            self.cursor_held_by = None;
1932        }
1933    }
1934
1935    fn hold_cursor(&mut self, score: u64, outpoint: &str) {
1936        if self
1937            .cursor_held_by
1938            .as_ref()
1939            .is_none_or(|(held, _)| score < *held)
1940        {
1941            self.cursor_held_by = Some((score, outpoint.to_string()));
1942        }
1943    }
1944
1945    fn root_expired(&self, node: &GASPNode, outpoint: &str) -> Result<bool, GASPError> {
1946        let now = (crate::engine::current_timestamp_ms() / 1000.0) as u64;
1947        let expiry = self.storage.sync_expiry(node, outpoint)?;
1948        if let Some(expiry) = expiry {
1949            self.root_expiries
1950                .borrow_mut()
1951                .insert(outpoint.to_string(), expiry);
1952        }
1953        let expiry = expiry.filter(|expiry| *expiry <= now);
1954        if expiry.is_some() {
1955            self.refused_expiry.set(expiry);
1956        }
1957        Ok(expiry.is_some())
1958    }
1959
1960    /// Load the remembered dead among `outpoints` into the run's memo; they
1961    /// are refused with no node fetch and are not counted again. A read
1962    /// fault is fail-safe: they are fetched and refused as before.
1963    async fn load_remembered_expired(&self, outpoints: Vec<String>) {
1964        let outpoints: Vec<String> = {
1965            let memo = self.expired.borrow();
1966            outpoints
1967                .into_iter()
1968                .filter(|o| !memo.contains(o))
1969                .collect()
1970        };
1971        if outpoints.is_empty() {
1972            return;
1973        }
1974        match self.storage.remembered_expired(&outpoints).await {
1975            Ok(dead) => self.expired.borrow_mut().extend(dead),
1976            Err(e) => warn!(
1977                "{} Could not read the remembered expired roots: {}",
1978                self.log_prefix, e
1979            ),
1980        }
1981    }
1982
1983    async fn refuse_expired(&self, outpoint: &str) -> Result<Ingested, GASPError> {
1984        let walk = self.walk.borrow_mut().take();
1985        let expiry = self.refused_expiry.take();
1986        if self.expired.borrow_mut().insert(outpoint.to_string()) {
1987            self.expired_refused.set(self.expired_refused.get() + 1);
1988            // Remembered across runs (0.1.8): the next sync skips it with no
1989            // node fetch. Best effort: a lost row costs one fetch again.
1990            if let Some(expiry) = expiry {
1991                if let Err(e) = self.storage.remember_expired(outpoint, expiry).await {
1992                    warn!(
1993                        "{} Could not remember expired root {}: {}",
1994                        self.log_prefix, outpoint, e
1995                    );
1996                }
1997            }
1998        }
1999        let _ = self
2000            .storage
2001            .discard_graph(&Self::graph_id_of(walk.as_ref(), outpoint))
2002            .await;
2003        if walk
2004            .as_ref()
2005            .is_some_and(|w| w.resumed || w.struck.is_some())
2006            || self.deferred.borrow_mut().remove(outpoint)
2007        {
2008            self.drop_record(outpoint, DropReason::Refused).await;
2009        }
2010        Ok(Ingested::Expired)
2011    }
2012
2013    /// Retire only fault facts whose own advert expired. No remote read.
2014    async fn sweep_expired_record(
2015        &self,
2016        record: Option<&DeferredGraph>,
2017    ) -> Result<bool, GASPError> {
2018        let Some(record) = record.filter(|r| r.is_unanchorable() || r.is_dead()) else {
2019            return Ok(false);
2020        };
2021        let outpoint = record.outpoint.as_str();
2022        self.read_record(Some(record));
2023        if record.expiry.is_none_or(|expiry| expiry > Self::now_secs())
2024            && !self.expired.borrow().contains(outpoint)
2025        {
2026            return Ok(false);
2027        }
2028        // Remember before deletion, so a later page cannot reopen an expired root.
2029        self.storage
2030            .remember_expired(outpoint, record.expiry.unwrap_or(Self::now_secs()))
2031            .await?;
2032        self.storage.delete_deferred_graph(outpoint).await?;
2033        self.forget_record(outpoint);
2034        self.deferred.borrow_mut().remove(outpoint);
2035        self.expired.borrow_mut().insert(outpoint.to_string());
2036        self.expired_swept.set(self.expired_swept.get() + 1);
2037        Ok(true)
2038    }
2039
2040    fn note_dead(&self, record: &DeferredGraph) {
2041        self.dead_recorded.set(self.dead_recorded.get() + 1);
2042        let mut faulted = self.faulted.borrow_mut();
2043        faulted.retain(|f| f.outpoint != record.outpoint);
2044        faulted.push(FaultedRoot {
2045            peer: String::new(),
2046            outpoint: record.outpoint.clone(),
2047            class: record.reason.clone(),
2048            faults: record.faults,
2049            unanchorable: true,
2050            error: format!("retry allowance spent: {}", record.unanchorable_retries),
2051            refused: String::new(),
2052        });
2053        warn!(
2054            "{} Root {} {} after {} retries",
2055            self.log_prefix, record.outpoint, record.reason, record.unanchorable_retries
2056        );
2057    }
2058
2059    async fn keep_unanchorable(&self, record: &DeferredGraph) -> Result<(), GASPError> {
2060        let mut kept = record.clone();
2061        kept.retry_at = Self::now_secs() + UNANCHORABLE_RETRY_SECS;
2062        if kept.unanchorable_retries >= UNANCHORABLE_RETRIES {
2063            kept.reason = format!(
2064                "{DEAD_REASON}{}",
2065                record.fault_class().unwrap_or(fault_class::OTHER)
2066            );
2067            kept.retry_at = 0;
2068        }
2069        if self.storage.save_deferred_graph(&kept).await? != DeferredGraphSave::Saved {
2070            return Err(GASPError::StorageError(
2071                "could not retain retry allowance".into(),
2072            ));
2073        }
2074        self.know_fault_record(&kept.outpoint);
2075        if kept.is_dead() && !record.is_dead() {
2076            self.note_dead(&kept);
2077        }
2078        Ok(())
2079    }
2080
2081    /// Unix seconds now, the clock of an unanchorable root's backoff.
2082    fn now_secs() -> u64 {
2083        (crate::engine::current_timestamp_ms() / 1000.0) as u64
2084    }
2085
2086    /// Mark the walk's failure with its fault class (engine 0.1.9) and hand
2087    /// the error back.
2088    fn faulted(&self, class: &'static str, error: GASPError) -> GASPError {
2089        if !matches!(error, GASPError::StorageError(_)) {
2090            self.last_fault.set(Some(class));
2091        }
2092        error
2093    }
2094
2095    /// The class of a peer's failure to serve a node: its `NodeNotFound`
2096    /// is its word that it does not hold the transaction (engine 0.1.15),
2097    /// any other failure is the moment's.
2098    fn peer_fault_class(error: &GASPError) -> &'static str {
2099        match error {
2100            GASPError::NodeNotFound(_) => fault_class::NOT_FOUND,
2101            GASPError::RouteFault { .. } => fault_class::ROUTE,
2102            _ => fault_class::PEER,
2103        }
2104    }
2105
2106    /// Name a dropped root once in this run's accounting (engine 0.1.9).
2107    fn note_faulted(
2108        &self,
2109        outpoint: &str,
2110        error: &GASPError,
2111        class: Option<&'static str>,
2112        struck: Strike,
2113    ) {
2114        let mut faulted = self.faulted.borrow_mut();
2115        if faulted.iter().any(|f| f.outpoint == outpoint) {
2116            return;
2117        }
2118        let (faults, unanchorable) = struck.held.unwrap_or((0, false));
2119        faulted.push(FaultedRoot {
2120            peer: String::new(),
2121            outpoint: outpoint.to_string(),
2122            class: class.unwrap_or(fault_class::OTHER).to_string(),
2123            faults,
2124            unanchorable,
2125            error: error.to_string().chars().take(160).collect(),
2126            refused: struck.refused.unwrap_or_default().to_string(),
2127        });
2128    }
2129
2130    /// A fresh root dropped in the page walk: count its faulted run when
2131    /// the failure is a fault and the root is the run's lowest gap, and name
2132    /// it either way. Whether it is now unanchorable (the cursor passes it).
2133    async fn strike_fresh(
2134        &self,
2135        score: u64,
2136        outpoint: &str,
2137        error: &GASPError,
2138        lowest: bool,
2139    ) -> bool {
2140        let class = self.last_fault.take();
2141        // Absence counts on every root of the run, not only the lowest
2142        // (0.1.12 for the rungs' word, 0.1.15 for the peer's): it is a
2143        // definite answer, one row of the bounded memo each (0.1.14), so a
2144        // page of absent roots passes together on its third gapped run.
2145        let struck = match class {
2146            Some(class)
2147                if (lowest || class == fault_class::NOT_FOUND)
2148                    && self.graph_budget.is_some()
2149                    && fault_class::strikes(class) =>
2150            {
2151                self.strike(score, outpoint, class, None).await
2152            }
2153            _ => Strike::default(),
2154        };
2155        self.note_faulted(outpoint, error, class, struck);
2156        struck.passable()
2157    }
2158
2159    /// A RESUMED walk of a held record dropped by a fault (engine 0.1.11):
2160    /// a root like any other, so a fault of a striking class
2161    /// ([`fault_class::strikes`]) counts a faulted run on the record's own
2162    /// slot ([`Self::strike`] with the record as `prior`: `faults`, the
2163    /// reason `retry:fault:<class>`, its score and its pass kept), and three
2164    /// make it `unanchorable:<class>`. Named in `faulted` with the count.
2165    /// `None` when no strike was written: no fault of the graph (a peer's
2166    /// fault of the moment, a storage fault, a bound), a pass that did not
2167    /// get the root (the peer no longer serves it: the record's own idle
2168    /// rule, engine 0.1.10), the record unread, or the strike refused
2169    /// (named `no_room` or `store_fault`); the caller then drops the record
2170    /// as before 0.1.11.
2171    async fn strike_resumed(
2172        &self,
2173        outpoint: &str,
2174        walk: Option<&Walk>,
2175        error: &GASPError,
2176    ) -> Option<Strike> {
2177        let class = self.last_fault.get().filter(|c| fault_class::strikes(c))?;
2178        // The root was never served this pass: the peer no longer offers
2179        // it, which says nothing of its graph.
2180        if walk.is_none_or(|w| w.record.nodes.is_empty()) {
2181            return None;
2182        }
2183        let prior = self.storage.get_deferred_graph(outpoint).await.ok()??;
2184        if prior.is_struck() {
2185            return None;
2186        }
2187        self.last_fault.set(None);
2188        let struck = self
2189            .strike(prior.score, outpoint, class, Some(&prior))
2190            .await;
2191        self.note_faulted(outpoint, error, Some(class), struck);
2192        struck.held.map(|_| struck)
2193    }
2194
2195    /// Count one more faulted run of `outpoint` (engine 0.1.9), on its
2196    /// record `prior` or on a new one: `retry:fault:<class>` (the cursor
2197    /// holds) under [`UNANCHORABLE_AFTER_FAULTED_RUNS`], `unanchorable:<class>`
2198    /// (passable, walked again after [`UNANCHORABLE_RETRY_SECS`]) at it. The
2199    /// record holds nothing but the root to ask for: the walk starts again
2200    /// from it. The storage keeps this compact fact in its bounded memo,
2201    /// separate from partial graphs. A strike that is not written (the
2202    /// storage's ceiling, a storage fault) is REFUSED: counted in
2203    /// [`Self::strikes_refused`] and named with why; a new root then stays a
2204    /// gap, a held unanchorable record stays as it was.
2205    async fn strike(
2206        &self,
2207        score: u64,
2208        outpoint: &str,
2209        class: &'static str,
2210        prior: Option<&DeferredGraph>,
2211    ) -> Strike {
2212        let refuse = |why: &'static str| {
2213            self.strikes_refused.set(self.strikes_refused.get() + 1);
2214            warn!(
2215                "{} Root {} faulted ({}): its strike was REFUSED ({}), the cursor holds (engine 0.1.10)",
2216                self.log_prefix, outpoint, class, why
2217            );
2218            Strike {
2219                // A held unanchorable record stays as it was: passable.
2220                held: prior
2221                    .filter(|r| r.is_unanchorable())
2222                    .map(|r| (r.faults, true)),
2223                refused: Some(why),
2224            }
2225        };
2226        let mut record = Self::fresh_walk(score, outpoint, PassCap::NONE).record;
2227        record.faults = prior.map_or(0, |r| r.faults).saturating_add(1);
2228        record.unanchorable_retries = prior.map_or(0, |r| r.unanchorable_retries);
2229        record.expiry = self
2230            .root_expiries
2231            .borrow()
2232            .get(outpoint)
2233            .copied()
2234            .or_else(|| prior.and_then(|r| r.expiry));
2235        // Engine 0.1.15: absence strikes on the three-run rule like every
2236        // class; on 0.1.12 to 0.1.14 the rungs' word struck at once.
2237        let unanchorable = record.faults >= UNANCHORABLE_AFTER_FAULTED_RUNS;
2238        // A record the cursor had passed stays passable (engine 0.1.11): the
2239        // strike counts its faulted runs and never takes the pass back.
2240        record.cursor_passable = unanchorable || prior.is_some_and(|r| r.cursor_passable);
2241        if unanchorable {
2242            record.reason = format!("{UNANCHORABLE_REASON}{class}");
2243            record.retry_at = Self::now_secs() + UNANCHORABLE_RETRY_SECS;
2244        } else {
2245            record.reason = format!("{FAULT_RETRY_REASON}{class}");
2246            // The next absence counts after the gap; until then the record
2247            // costs no request (engine 0.1.15).
2248            if class == fault_class::NOT_FOUND {
2249                record.retry_at = Self::now_secs() + ABSENCE_GAP_SECS;
2250            }
2251        }
2252        if unanchorable && record.unanchorable_retries >= UNANCHORABLE_RETRIES {
2253            record.reason = format!("{DEAD_REASON}{class}");
2254            record.retry_at = 0;
2255        }
2256        match self.storage.save_deferred_graph(&record).await {
2257            Ok(DeferredGraphSave::Saved) => {
2258                if record.is_dead() && !prior.is_some_and(DeferredGraph::is_dead) {
2259                    self.note_dead(&record);
2260                }
2261                // A new key holds a fault record and no deferred slot; a
2262                // held record struck on its own slot frees its deferred
2263                // slot for the fault record (engine 0.1.11); a prior fault
2264                // record was known when it was read.
2265                if prior.is_some() {
2266                    self.know_fault_record(outpoint);
2267                } else {
2268                    self.fault_records.borrow_mut().insert(outpoint.to_string());
2269                }
2270                if unanchorable && !prior.is_some_and(DeferredGraph::is_unanchorable) {
2271                    self.unanchorable_recorded
2272                        .set(self.unanchorable_recorded.get() + 1);
2273                    if class == fault_class::NOT_FOUND {
2274                        self.absent_struck.set(self.absent_struck.get() + 1);
2275                    }
2276                    warn!(
2277                        "{} Root {} UNANCHORABLE ({}): faulted {} runs in a row; the cursor passes it, walked again in {} s (engine 0.1.9)",
2278                        self.log_prefix, outpoint, class, record.faults, UNANCHORABLE_RETRY_SECS
2279                    );
2280                } else {
2281                    warn!(
2282                        "{} Root {} faulted ({}): run {} of {} (engine 0.1.9)",
2283                        self.log_prefix,
2284                        outpoint,
2285                        class,
2286                        record.faults,
2287                        UNANCHORABLE_AFTER_FAULTED_RUNS
2288                    );
2289                }
2290                Strike {
2291                    held: Some((record.faults, unanchorable)),
2292                    refused: None,
2293                }
2294            }
2295            Ok(DeferredGraphSave::AtCeiling) => refuse(strike_refused::NO_ROOM),
2296            Err(_) => refuse(strike_refused::STORE_FAULT),
2297        }
2298    }
2299
2300    /// Walk again the root of a fault record (engine 0.1.9), under this
2301    /// pass's resume allowance. An unanchorable one waits out its backoff
2302    /// (no request at all). The walk is fresh from the root and holds the
2303    /// record's slot: a landing or a verdict clears the record, a budget
2304    /// cut or a deadline replaces it with the hold it earned, and a fault
2305    /// counts one more run on it.
2306    async fn rewalk_struck(
2307        &self,
2308        utxo: &GASPOutput,
2309        outpoint: &str,
2310        mut record: DeferredGraph,
2311        deadline: &mut crate::engine::SleepFuture,
2312        cap: PassCap,
2313    ) -> Result<Ingested, GASPError> {
2314        let score = utxo.score as u64;
2315        let absent = record.fault_class() == Some(fault_class::NOT_FOUND);
2316        if record.is_dead() || (record.is_unanchorable() && Self::now_secs() < record.retry_at) {
2317            // Not asked of the peer: not an attempt for the yieldless
2318            // streak (a quiet peer holding one is never quarantined for it).
2319            self.graphs_attempted
2320                .set(self.graphs_attempted.get().saturating_sub(1));
2321            if absent {
2322                crate::meter::peer_absent_memo_hit();
2323            }
2324            debug!(
2325                "{} Unanchorable root {} waits until {} (engine 0.1.9)",
2326                self.log_prefix, outpoint, record.retry_at
2327            );
2328            return Ok(Ingested::Unanchorable);
2329        }
2330        // Engine 0.1.15: an absence counted less than ABSENCE_GAP_SECS ago
2331        // is not asked again; the run counts nothing and the cursor holds
2332        // below a root it has not passed.
2333        if !record.is_unanchorable() && Self::now_secs() < record.retry_at {
2334            self.graphs_attempted
2335                .set(self.graphs_attempted.get().saturating_sub(1));
2336            if absent {
2337                crate::meter::peer_absent_memo_hit();
2338            }
2339            debug!(
2340                "{} Absent root {} counted {} runs; the next count waits until {} (engine 0.1.15)",
2341                self.log_prefix, outpoint, record.faults, record.retry_at
2342            );
2343            return Ok(if record.cursor_passable {
2344                Ingested::Deferred
2345            } else {
2346                Ingested::Retry
2347            });
2348        }
2349        if record.is_unanchorable() {
2350            if record.unanchorable_retries >= UNANCHORABLE_RETRIES {
2351                self.keep_unanchorable(&record).await?;
2352                self.graphs_attempted
2353                    .set(self.graphs_attempted.get().saturating_sub(1));
2354                return Ok(Ingested::Unanchorable);
2355            }
2356            // Reserve before dispatch: cancellation cannot buy another retry.
2357            record.unanchorable_retries += 1;
2358            record.retry_at = Self::now_secs() + UNANCHORABLE_RETRY_SECS;
2359            if !self.storage.reserve_unanchorable_retry(&record).await? {
2360                self.graphs_attempted
2361                    .set(self.graphs_attempted.get().saturating_sub(1));
2362                return Ok(Ingested::Unanchorable);
2363            }
2364        }
2365        info!(
2366            "{} Walking root {} again after {} faulted runs ({}) (engine 0.1.9)",
2367            self.log_prefix, outpoint, record.faults, record.reason
2368        );
2369        crate::meter::walk_output();
2370        let mut walk = Self::fresh_walk(score, outpoint, cap);
2371        walk.struck = Some(Box::new(record.clone()));
2372        *self.walk.borrow_mut() = Some(walk);
2373        let ended = self.walk_graph(Some(&mut *deadline)).await;
2374        let out = self.finish_pass(outpoint, ended, deadline).await;
2375        match out {
2376            Ok(ingested)
2377                if record.is_unanchorable()
2378                    && !matches!(ingested, Ingested::Completed | Ingested::Expired) =>
2379            {
2380                self.keep_unanchorable(&record).await?;
2381                Ok(Ingested::Unanchorable)
2382            }
2383            // A stall of the graph's own deadline does not undo the record:
2384            // the empty retry `save_walk` wrote is replaced again. So for a
2385            // passable fault record struck on a held record's slot (engine
2386            // 0.1.11): the empty retry would hold the cursor it had passed.
2387            Ok(Ingested::Retry) if record.cursor_passable => {
2388                let unanchorable = record.is_unanchorable();
2389                let kept = record.clone();
2390                if let Ok(DeferredGraphSave::Saved) = self.storage.save_deferred_graph(&kept).await
2391                {
2392                    // A fault record again, not a deferred slot (0.1.10).
2393                    self.know_fault_record(outpoint);
2394                }
2395                Ok(if unanchorable {
2396                    Ingested::Unanchorable
2397                } else {
2398                    Ingested::Deferred
2399                })
2400            }
2401            Ok(ingested) => Ok(ingested),
2402            Err(error) => {
2403                self.walk.borrow_mut().take();
2404                let class = self.last_fault.take();
2405                let Some(class) = class.filter(|c| fault_class::strikes(c)) else {
2406                    // No fault of the graph's: the record stands.
2407                    self.note_faulted(
2408                        outpoint,
2409                        &error,
2410                        class,
2411                        Strike {
2412                            held: Some((record.faults, record.is_unanchorable())),
2413                            refused: None,
2414                        },
2415                    );
2416                    if record.is_unanchorable() {
2417                        self.keep_unanchorable(&record).await?;
2418                        return Ok(Ingested::Unanchorable);
2419                    }
2420                    return Err(error);
2421                };
2422                let struck = self.strike(score, outpoint, class, Some(&record)).await;
2423                self.note_faulted(outpoint, &error, Some(class), struck);
2424                if struck.passable() {
2425                    Ok(Ingested::Unanchorable)
2426                } else {
2427                    Err(error)
2428                }
2429            }
2430        }
2431    }
2432
2433    /// Bound each graph's walk and retain its pending work. A successfully
2434    /// saved hold permits cursor progress; an unsaved graph remains a gap.
2435    /// Bound the resumes of each pass by `share` (engine 0.1.11): ONE
2436    /// deadline for every held record walked ahead of the page, started when
2437    /// the held loop starts, so the page keeps the rest of the per-peer
2438    /// slice (see [`RESUMES_SHARE_OF_PEER_BUDGET_PERCENT`]). A record reached
2439    /// once it fell due is held back for the next sync. Without one the
2440    /// resumes share one per-graph deadline (the lens fold's M1).
2441    #[must_use]
2442    pub fn with_resume_share(
2443        mut self,
2444        share: Box<dyn Fn() -> crate::engine::SleepFuture + 'a>,
2445    ) -> Self {
2446        self.resume_share = Some(share);
2447        self
2448    }
2449
2450    pub fn with_graph_budget(mut self, budget: GraphBudget<'a>) -> Self {
2451        self.graph_budget = Some(budget);
2452        self
2453    }
2454
2455    /// What the last `sync` did with deferred graphs (bsv-low #555).
2456    pub fn deferral_stats(&self) -> DeferralStats {
2457        self.stats.borrow().clone()
2458    }
2459
2460    /// How many UTXOs the sync set out to ingest (a new walk, a resume, or a
2461    /// record held back): the peer served it work it did not hold. A sync
2462    /// that attempted none was quiet (bsv-low #555, the delta fold's D-M2:
2463    /// a quiet sync never counts toward [`PEER_YIELDLESS_SYNCS_ALLOWED`]).
2464    pub fn graphs_attempted(&self) -> u64 {
2465        self.graphs_attempted.get()
2466    }
2467
2468    /// Have `hook` called after every completed incoming UTXO (bsv-low #552,
2469    /// see [`FinalizedGraphHook`]). Without one `sync` is unchanged.
2470    #[must_use]
2471    pub fn with_finalized_graph_hook(mut self, hook: Box<dyn FinalizedGraphHook + 'a>) -> Self {
2472        self.finalized_hook = Some(hook);
2473        self
2474    }
2475
2476    /// The cursor that is safe to persist if `sync` stopped RIGHT NOW (its
2477    /// future dropped at a deadline; bsv-low #552). It is the gap guard's
2478    /// rule applied to completed scores: never above a completed output, and
2479    /// strictly below the lowest score of
2480    /// any UTXO of the current page not yet completed (the one in flight
2481    /// included) and of any UTXO whose ingest failed, never below the cursor
2482    /// the sync entered with. The remote serves `score >= since`, so the next
2483    /// sync is served everything this one did not finish. After a completed
2484    /// `sync` it equals `last_interaction`.
2485    ///
2486    /// `last_interaction` itself is NOT safe after a drop: it is raised to
2487    /// each UTXO's score BEFORE that UTXO is ingested, for pagination.
2488    pub fn completed_cursor(&self) -> u64 {
2489        self.completed_cursor
2490    }
2491
2492    /// Whether a graph was being fetched, walked or anchor-checked when `sync`
2493    /// last stopped: `true` only after a `sync` future dropped mid-graph
2494    /// (bsv-low #552). That graph's nodes died with the storage adapter;
2495    /// nothing of it was finalized.
2496    pub fn graph_in_flight(&self) -> bool {
2497        self.graph_in_flight
2498    }
2499
2500    /// Highest completed score below every gap, floored at the entry cursor.
2501    /// Retaining scores handles unordered pages without inventing a cursor
2502    /// between outputs or skipping an unfinished member of a score tie.
2503    fn cursor_for_completed(
2504        completed: &std::collections::BTreeSet<u64>,
2505        lowest_unfinished: Option<u64>,
2506        floor: u64,
2507    ) -> u64 {
2508        let highest = match lowest_unfinished {
2509            Some(score) => completed.range(..score).next_back(),
2510            None => completed.iter().next_back(),
2511        };
2512        highest.copied().unwrap_or(floor).max(floor)
2513    }
2514
2515    /// How many graphs this orchestrator's last `sync` discarded because
2516    /// `validate_graph_anchor` REFUSED them (a root that does not verify, or
2517    /// one the topic manager does not admit at the end of the replay; bsv-low
2518    /// #551). Nothing of a discarded graph is finalized. As in the reference
2519    /// a refused graph is not a failed UTXO: the cursor advances past it. A
2520    /// graph whose anchor could not be CHECKED
2521    /// (`GASPError::AnchorUnavailable`: a tracker or storage fault, a topic
2522    /// manager error in the replay) is not counted here: that one fails its
2523    /// UTXO and is asked for again.
2524    pub fn discarded_graphs(&self) -> u64 {
2525        self.discarded_graphs.get()
2526    }
2527
2528    /// How many graphs this orchestrator's last `sync` HELD because their
2529    /// anchor's script walk could not run within the engine's walk budget
2530    /// (`GASPError::AnchorWalkHeld`; bsv-low #592, the E592 lens fold): not
2531    /// finalized, the UTXO failed and the cursor kept below it, until the
2532    /// peer serves the root proven. Not counted in `discarded_graphs`.
2533    pub fn anchor_walk_held(&self) -> u64 {
2534        self.anchor_walk_held.get()
2535    }
2536
2537    /// How many manager-named inputs this orchestrator pruned because the
2538    /// peer answered that it does not hold them (the D8 decoy rule, see
2539    /// `process_incoming_node`). Counted per DISTINCT outpoint per `sync`
2540    /// call of this orchestrator, which talks to ONE peer: the same decoy
2541    /// seen through two peers is counted by each. A pruned branch is NOT a
2542    /// failed UTXO: the graph completes with what it has and the cursor
2543    /// advances.
2544    pub fn pruned_inputs(&self) -> u64 {
2545        self.pruned_inputs.get()
2546    }
2547
2548    /// Opt in to chain-backed ancestor hydration (OFF by default).
2549    ///
2550    /// When set, a peer's inability to serve a needed ancestor during ingest
2551    /// triggers a fallback fetch of that ancestor's raw tx from chain. This is
2552    /// a deliberate one-time-migration escape hatch — production should NOT
2553    /// call this. Without it, behavior is unchanged.
2554    #[must_use]
2555    pub fn with_ancestor_fetcher(
2556        mut self,
2557        fetcher: Option<std::rc::Rc<dyn AncestorFetcher + 'a>>,
2558    ) -> Self {
2559        self.ancestor_fetcher = fetcher;
2560        self
2561    }
2562
2563    /// Run the sync protocol with the remote peer.
2564    ///
2565    /// 1. Request remote's UTXOs since last interaction (paginated)
2566    /// 2. For each unknown UTXO, request the full graph and ingest it
2567    /// 3. If bidirectional, push our unknown UTXOs to the remote
2568    pub async fn sync(&mut self, limit: Option<u64>) -> Result<(), GASPError> {
2569        info!(
2570            "{} Starting sync. last_interaction={}",
2571            self.log_prefix, self.last_interaction
2572        );
2573        self.page.pages = 0;
2574        self.page.advanced = false;
2575        self.page.reached_tip = false;
2576        self.page.floor = None;
2577        self.pruned.borrow_mut().clear();
2578        self.pruned_inputs.set(0);
2579        self.absent.borrow_mut().clear();
2580        self.batch.queue.borrow_mut().clear();
2581        self.batch.prefetched.borrow_mut().clear();
2582        self.expired_refused.set(0);
2583        self.root_proofs.borrow_mut().clear();
2584        self.roots_proven.set(0);
2585        self.roots_unproven.set(0);
2586        self.cursor_held_by = None;
2587        self.in_flight = None;
2588        self.retained.set(0);
2589        self.resumed_with_progress.set(0);
2590        self.cursor_yielded.set(false);
2591        self.last_fault.set(None);
2592        self.faulted.borrow_mut().clear();
2593        self.unanchorable_recorded.set(0);
2594        self.unanchorable_cleared.set(0);
2595        self.absent_struck.set(0);
2596        self.dead_recorded.set(0);
2597        self.expired_swept.set(0);
2598        self.memo_folded.set(0);
2599        self.root_expiries.borrow_mut().clear();
2600        self.strikes_refused.set(0);
2601        self.fault_records.borrow_mut().clear();
2602        self.discarded_graphs.set(0);
2603        self.anchor_walk_held.set(0);
2604        self.completed_cursor = self.last_interaction;
2605        self.graph_in_flight = false;
2606        self.walk.borrow_mut().take();
2607        *self.stats.borrow_mut() = DeferralStats::default();
2608        self.deferred.borrow_mut().clear();
2609        self.held_records.set(0);
2610        self.resume_deadline.borrow_mut().take();
2611        self.resume_left.set(
2612            self.graph_budget
2613                .as_ref()
2614                .map_or(PassCap::NONE, GraphBudget::pass),
2615        );
2616        // Load only keys here. Records are resumed independently of pages,
2617        // one at a time under the shared resume allowance. A key-read fault
2618        // conservatively reserves all slots for the records already held.
2619        let mut held_keys = Vec::new();
2620        if self.graph_budget.is_some() {
2621            let memo = self.storage.read_fault_memos(None).await?;
2622            self.memo_folded.set(memo.folded);
2623            self.expired_swept.set(memo.swept);
2624            match self.storage.load_deferred_graphs().await {
2625                Ok(keys) => {
2626                    held_keys.clone_from(&keys);
2627                    self.held_records.set(keys.len());
2628                    self.deferred
2629                        .borrow_mut()
2630                        .extend(keys.into_iter().map(|k| k.outpoint));
2631                }
2632                Err(e) => {
2633                    self.held_records.set(DEFERRED_GRAPHS_PER_PEER_TOPIC);
2634                    warn!(
2635                        "{} Could not read the deferred graphs (bsv-low #555): {}",
2636                        self.log_prefix, e
2637                    );
2638                }
2639            }
2640            for record in memo.records {
2641                held_keys.retain(|key| key.outpoint != record.outpoint);
2642                self.fault_records
2643                    .borrow_mut()
2644                    .insert(record.outpoint.clone());
2645                self.deferred.borrow_mut().insert(record.outpoint.clone());
2646                held_keys.push(DeferredGraphKey {
2647                    outpoint: record.outpoint,
2648                    score: record.score,
2649                });
2650            }
2651            held_keys.sort_by(|a, b| (a.score, &a.outpoint).cmp(&(b.score, &b.outpoint)));
2652        }
2653
2654        // Track what we already know
2655        let local_utxos = self.storage.find_known_utxos(0, None).await?;
2656        let mut known_outpoints: std::collections::HashSet<String> = local_utxos
2657            .iter()
2658            .map(|u| format!("{}.{}", u.txid, u.output_index))
2659            .collect();
2660        let mut shared_outpoints: std::collections::HashSet<String> =
2661            std::collections::HashSet::new();
2662
2663        // The cursor we entered with. The persisted cursor must never regress
2664        // below it (a failed RE-ingest of an already-synced range is not a new
2665        // gap), and the gap-guard below is floored at it.
2666        let initial_interaction = self.last_interaction;
2667        let mut completed_scores = std::collections::BTreeSet::new();
2668        // Lowest score of any UTXO whose graph ingest FAILED this run. The
2669        // in-loop `last_interaction` advance below is by max-seen-score so
2670        // pagination terminates, but a transient ingest failure must NOT let the
2671        // PERSISTED cursor skip past that output — else the peer never re-serves
2672        // it (next `since` excludes scores below the cursor) and it is stranded
2673        // forever. After the loop we cap the cursor strictly below this score so
2674        // the next sync re-requests the failed graph. (Without this, a single
2675        // blipped graph fetch silently drops that UTXO from continuous sync.)
2676        let mut min_failed_score: Option<u64> = None;
2677        // The UTXOs whose ingest FAILED this run (bsv-low #554). The cursor
2678        // moves to a UTXO's score before its ingest and the responder serves
2679        // `score >= since`, so the row at a page boundary is served again on
2680        // the next page of this same sync. A completed one is in
2681        // `shared_outpoints` and skipped; a failed one was in no set and was
2682        // walked a second time, at twice the requests and twice the per-peer
2683        // budget. An addition to the reference: `GASP.ts sync` keeps only
2684        // `sharedOutpoints` (added on success), so a failed UTXO served
2685        // again by a later page is ingested again there. Here a UTXO is
2686        // ingested at most once per sync; the retry is the next sync's, which
2687        // the gap guard below makes sure is served it. Kept apart from
2688        // `shared_outpoints`, which also decides what a bidirectional sync
2689        // pushes.
2690        let mut failed_outpoints: std::collections::HashSet<String> =
2691            std::collections::HashSet::new();
2692
2693        // Saved graphs are work independent of the peer's pagination cursor.
2694        // Read one record at a time and share the existing resume allowance.
2695        // A held root remembered dead is refused with no fetch.
2696        self.load_remembered_expired(held_keys.iter().map(|k| k.outpoint.clone()).collect())
2697            .await;
2698        // Engine 0.1.11: the resumes' share starts with the held loop, so the
2699        // storage's calls between resumes are inside it (polled once now, so
2700        // a timer that starts when it is polled runs).
2701        if !held_keys.is_empty() && self.graph_budget.is_some() {
2702            if let Some(share) = &self.resume_share {
2703                let mut deadline = latched(share());
2704                let _ = Self::due(&mut deadline).await;
2705                *self.resume_deadline.borrow_mut() = Some(deadline);
2706            }
2707        }
2708        let held_scores: std::collections::HashMap<String, u64> = held_keys
2709            .iter()
2710            .map(|key| (key.outpoint.clone(), key.score))
2711            .collect();
2712        for key in held_keys {
2713            if self.request_stopped() {
2714                break;
2715            }
2716            if known_outpoints.contains(&key.outpoint) {
2717                self.deferred.borrow_mut().remove(&key.outpoint);
2718                self.drop_record(&key.outpoint, DropReason::Held).await;
2719                shared_outpoints.insert(key.outpoint);
2720                continue;
2721            }
2722            let Some((txid, output_index)) = parse_outpoint(&key.outpoint) else {
2723                continue;
2724            };
2725            let utxo = GASPOutput {
2726                txid,
2727                output_index,
2728                score: key.score as f64,
2729            };
2730            self.graph_in_flight = true;
2731            self.in_flight = Some((key.score, key.outpoint.clone()));
2732            self.cursor_after_in_flight = initial_interaction;
2733            self.hold_cursor(key.score, &key.outpoint);
2734            let result = self.ingest_utxo(&utxo, &key.outpoint).await;
2735            self.graph_in_flight = false;
2736            self.in_flight = None;
2737            let settled = match result {
2738                Ok(Ingested::Completed) => {
2739                    let landed = match &self.finalized_hook {
2740                        Some(hook) => hook.graph_completed().await.is_ok(),
2741                        None => true,
2742                    };
2743                    self.settle_completed(landed).await;
2744                    if landed {
2745                        self.cursor_yielded.set(true);
2746                    }
2747                    landed
2748                }
2749                Ok(Ingested::Expired) => {
2750                    self.cursor_yielded.set(true);
2751                    true
2752                }
2753                Ok(
2754                    Ingested::Deferred
2755                    | Ingested::HeldBack
2756                    | Ingested::Unanchorable
2757                    | Ingested::Waiting,
2758                ) => true,
2759                Ok(Ingested::Retry) => false,
2760                Err(error) => {
2761                    warn!(
2762                        "{} Deferred graph {} retry failed: {}",
2763                        self.log_prefix, key.outpoint, error
2764                    );
2765                    self.walk.borrow_mut().take();
2766                    let class = self.last_fault.take();
2767                    self.note_faulted(&key.outpoint, &error, class, Strike::default());
2768                    false
2769                }
2770            };
2771            let settled = settled
2772                || self
2773                    .storage
2774                    .get_deferred_graph(&key.outpoint)
2775                    .await
2776                    .ok()
2777                    .flatten()
2778                    .is_some_and(|r| r.cursor_passable);
2779            if settled {
2780                if self
2781                    .cursor_held_by
2782                    .as_ref()
2783                    .is_some_and(|(_, held)| held == &key.outpoint)
2784                {
2785                    self.cursor_held_by = None;
2786                }
2787                // A saved graph settles only itself. The current page must
2788                // account for any earlier unsaved gap before its score can
2789                // contribute to cursor movement.
2790                shared_outpoints.insert(key.outpoint);
2791            } else {
2792                self.hold_cursor(key.score, &key.outpoint);
2793                if key.score >= initial_interaction {
2794                    min_failed_score =
2795                        Some(min_failed_score.map_or(key.score, |s| s.min(key.score)));
2796                }
2797                failed_outpoints.insert(key.outpoint);
2798            }
2799        }
2800
2801        // Paginated pull from remote
2802        'pages: loop {
2803            if self.request_stopped() {
2804                break;
2805            }
2806            let cursor_before_page = self.last_interaction;
2807            // Every UTXO seen so far is completed or failed: were the sync
2808            // dropped inside the page request below, this is what it did.
2809            self.completed_cursor = Self::cursor_for_completed(
2810                &completed_scores,
2811                min_failed_score,
2812                initial_interaction,
2813            );
2814            let request = GASPInitialRequest {
2815                version: GASP_VERSION,
2816                since: self.last_interaction,
2817                limit,
2818            };
2819            let response = self.remote.get_initial_response(&request).await?;
2820            let page_size = response.utxo_list.len();
2821            self.page.pages += 1;
2822
2823            info!(
2824                "{} Processing page with {} UTXOs (since: {})",
2825                self.log_prefix, page_size, response.since
2826            );
2827
2828            // The page's remembered dead are refused with no node fetch.
2829            self.load_remembered_expired(
2830                response
2831                    .utxo_list
2832                    .iter()
2833                    .map(|u| format!("{}.{}", u.txid, u.output_index))
2834                    .filter(|o| !known_outpoints.contains(o))
2835                    .collect(),
2836            )
2837            .await;
2838
2839            if self.graph_budget.is_some() {
2840                let outpoints: Vec<_> = response
2841                    .utxo_list
2842                    .iter()
2843                    .map(|u| format!("{}.{}", u.txid, u.output_index))
2844                    .collect();
2845                let memo = self.storage.read_fault_memos(Some(&outpoints)).await?;
2846                for record in memo.records {
2847                    let changed = response.utxo_list.iter().any(|u| {
2848                        format!("{}.{}", u.txid, u.output_index) == record.outpoint
2849                            && u.score as u64 != record.score
2850                    });
2851                    if changed && (record.dead || record.unanchorable) {
2852                        shared_outpoints.remove(&record.outpoint);
2853                        failed_outpoints.remove(&record.outpoint);
2854                    }
2855                    self.fault_records
2856                        .borrow_mut()
2857                        .insert(record.outpoint.clone());
2858                    self.deferred.borrow_mut().insert(record.outpoint);
2859                }
2860            }
2861
2862            // Engine 0.2.1 (#58): the outputs this page will ask the peer for
2863            // as fresh roots, so a peer that answers the batch is asked for
2864            // them in as few bodies as the budget allows.
2865            self.queue_batch(
2866                response
2867                    .utxo_list
2868                    .iter()
2869                    .map(|u| format!("{}.{}", u.txid, u.output_index)),
2870                |o| {
2871                    !known_outpoints.contains(o)
2872                        && !shared_outpoints.contains(o)
2873                        && !failed_outpoints.contains(o)
2874                },
2875            );
2876
2877            // lowest_ahead[i]: the lowest score among this page's UTXOs from
2878            // i on, for `completed_cursor` (a page is not assumed sorted).
2879            let mut lowest_ahead: Vec<u64> =
2880                response.utxo_list.iter().map(|u| u.score as u64).collect();
2881            for i in (0..lowest_ahead.len().saturating_sub(1)).rev() {
2882                lowest_ahead[i] = lowest_ahead[i].min(lowest_ahead[i + 1]);
2883            }
2884
2885            // The request may have returned an unordered page. Hold below all
2886            // its unfinished outputs before any graph or storage await.
2887            let unfinished = match lowest_ahead.as_slice() {
2888                [lowest, ..] => Some(min_failed_score.map_or(*lowest, |score| score.min(*lowest))),
2889                [] => min_failed_score,
2890            };
2891            self.completed_cursor =
2892                Self::cursor_for_completed(&completed_scores, unfinished, initial_interaction);
2893
2894            // Engine 0.2.2: the page's admissions are written in one batch
2895            // at its end, and the applied state of its fresh roots is read
2896            // once now. Only where they are made inside the sync (the
2897            // finalize hook): without it they are submitted after the sync,
2898            // one at a time, as before.
2899            let paged = self.finalized_hook.is_some();
2900            if paged {
2901                self.page.floor = Some(self.completed_cursor);
2902                self.storage.open_page();
2903            }
2904            let roots: Vec<String> = response
2905                .utxo_list
2906                .iter()
2907                .filter(|u| {
2908                    let o = format!("{}.{}", u.txid, u.output_index);
2909                    !known_outpoints.contains(&o)
2910                        && !shared_outpoints.contains(&o)
2911                        && !failed_outpoints.contains(&o)
2912                })
2913                .map(|u| u.txid.clone())
2914                .collect();
2915            if paged && !roots.is_empty() {
2916                self.storage.prefetch_page(&roots, &[]).await;
2917            }
2918
2919            for (position, utxo) in response.utxo_list.iter().enumerate() {
2920                if self.request_stopped() {
2921                    let score = lowest_ahead[position];
2922                    min_failed_score = Some(min_failed_score.map_or(score, |s| s.min(score)));
2923                    break 'pages;
2924                }
2925                // Track highest score for pagination
2926                if utxo.score as u64 > self.last_interaction {
2927                    self.last_interaction = utxo.score as u64;
2928                }
2929
2930                let outpoint = format!("{}.{}", utxo.txid, utxo.output_index);
2931                if held_scores
2932                    .get(&outpoint)
2933                    .is_some_and(|score| *score != utxo.score as u64)
2934                    && self
2935                        .storage
2936                        .get_deferred_graph(&outpoint)
2937                        .await?
2938                        .is_some_and(|r| r.is_unanchorable() || r.is_dead())
2939                {
2940                    shared_outpoints.remove(&outpoint);
2941                    failed_outpoints.remove(&outpoint);
2942                    self.deferred.borrow_mut().insert(outpoint.clone());
2943                }
2944                if known_outpoints.contains(&outpoint) {
2945                    self.cursor_yielded.set(true);
2946                    // A deferred graph whose UTXO the node now holds (it
2947                    // landed another way): nothing to resume.
2948                    if self.deferred.borrow_mut().remove(&outpoint) {
2949                        self.drop_record(&outpoint, DropReason::Held).await;
2950                    }
2951                    shared_outpoints.insert(outpoint.clone());
2952                    known_outpoints.remove(&outpoint);
2953                } else if !shared_outpoints.contains(&outpoint)
2954                    && !failed_outpoints.contains(&outpoint)
2955                {
2956                    // New UTXO — request and ingest the graph
2957                    let lowest_unfinished = min_failed_score
2958                        .map_or(lowest_ahead[position], |f| f.min(lowest_ahead[position]));
2959                    self.completed_cursor = Self::cursor_for_completed(
2960                        &completed_scores,
2961                        Some(lowest_unfinished),
2962                        initial_interaction,
2963                    );
2964                    let mut with_current = completed_scores.clone();
2965                    with_current.insert(utxo.score as u64);
2966                    let remaining = lowest_ahead
2967                        .get(position + 1)
2968                        .copied()
2969                        .into_iter()
2970                        .chain(min_failed_score)
2971                        .min();
2972                    self.cursor_after_in_flight =
2973                        Self::cursor_for_completed(&with_current, remaining, initial_interaction);
2974                    self.hold_cursor(utxo.score as u64, &outpoint);
2975                    self.graph_in_flight = true;
2976                    self.in_flight = Some((utxo.score as u64, outpoint.clone()));
2977                    let ingested = self.ingest_utxo(utxo, &outpoint).await;
2978                    self.graph_in_flight = false;
2979                    self.in_flight = None;
2980                    let ingested = match ingested {
2981                        Ok(Ingested::Expired) => {
2982                            self.cursor_yielded.set(true);
2983                            Ok(true)
2984                        }
2985                        Ok(Ingested::Completed) => {
2986                            let landed = match &self.finalized_hook {
2987                                Some(hook) => hook.graph_completed().await,
2988                                None => Ok(()),
2989                            };
2990                            self.settle_completed(landed.is_ok()).await;
2991                            if landed.is_ok() {
2992                                self.cursor_yielded.set(true);
2993                            }
2994                            landed.map(|()| true)
2995                        }
2996                        Ok(
2997                            Ingested::Deferred
2998                            | Ingested::HeldBack
2999                            | Ingested::Unanchorable
3000                            | Ingested::Waiting,
3001                        ) => Ok(true),
3002                        Ok(Ingested::Retry) => Ok(false),
3003                        Err(e) => {
3004                            self.walk.borrow_mut().take();
3005                            Err(e)
3006                        }
3007                    };
3008                    match ingested {
3009                        Ok(true) => {
3010                            if self
3011                                .cursor_held_by
3012                                .as_ref()
3013                                .is_some_and(|(_, held)| held == &outpoint)
3014                            {
3015                                self.cursor_held_by = None;
3016                            }
3017                            shared_outpoints.insert(outpoint.clone());
3018                        }
3019                        // An empty deadline retry has not earned a durable
3020                        // hold yet; retain the gap until its named threshold.
3021                        Ok(false) => {
3022                            let s = utxo.score as u64;
3023                            min_failed_score = Some(min_failed_score.map_or(s, |cur| cur.min(s)));
3024                            failed_outpoints.insert(outpoint.clone());
3025                        }
3026                        Err(e) => {
3027                            warn!(
3028                                "{} Error ingesting UTXO {}: {}",
3029                                self.log_prefix, outpoint, e
3030                            );
3031                            let s = utxo.score as u64;
3032                            // Engine 0.1.9: a FAULT of the lowest gap of the
3033                            // run counts the root's opening faulted run (a
3034                            // record `retry:fault:<class>`); its next runs
3035                            // walk it ahead of the pages, and the cursor
3036                            // passes it once it is recorded unanchorable.
3037                            // Only the lowest: one new record a (peer, topic)
3038                            // a run, so a peer that faults every node cannot
3039                            // fill the store in one tick (the delta fold's
3040                            // D-M1).
3041                            let lowest = min_failed_score.is_none_or(|m| s < m);
3042                            if self.strike_fresh(s, &outpoint, &e, lowest).await {
3043                                self.release_hold(&outpoint);
3044                                shared_outpoints.insert(outpoint.clone());
3045                            } else {
3046                                // Remember the lowest failed score so the
3047                                // persisted cursor cannot skip past it (see
3048                                // cap below).
3049                                min_failed_score =
3050                                    Some(min_failed_score.map_or(s, |cur| cur.min(s)));
3051                                // Not again in this sync (bsv-low #554).
3052                                failed_outpoints.insert(outpoint.clone());
3053                            }
3054                        }
3055                    }
3056                }
3057                // Known outputs and graphs whose finalize hook landed are
3058                // settled. Explicit anchor refusals retain the existing
3059                // terminal-discard rule. Saved holds can be passed; faults
3060                // without a durable hold retain a gap.
3061                if shared_outpoints.contains(&outpoint) {
3062                    completed_scores.insert(utxo.score as u64);
3063                }
3064                let unfinished = lowest_ahead
3065                    .get(position + 1)
3066                    .copied()
3067                    .into_iter()
3068                    .chain(min_failed_score)
3069                    .min();
3070                self.completed_cursor =
3071                    Self::cursor_for_completed(&completed_scores, unfinished, initial_interaction);
3072            }
3073
3074            self.close_page().await?;
3075
3076            // Keep the settled frontier and any completed scores above a gap.
3077            // An advancing page needs no older score again.
3078            completed_scores.retain(|score| *score >= self.completed_cursor);
3079
3080            // Pagination termination (bsv-low #291 gate findings M1/LOW-A):
3081            // keep paging on ANY page that advanced the score cursor —
3082            // with or without a requested limit. The old rule ("continue
3083            // only on a full page"; `None` never paged at all) silently
3084            // terminated after one page against a responder that CLAMPS
3085            // the page size (Engine::clamp_sync_limit clamps BOTH an
3086            // over-large and an ABSENT limit) — a clamped page is never
3087            // "full" by our limit, yet more rows remain. A non-advancing
3088            // page (empty, or only rows at scores we already hold — the
3089            // responder serves `score >= since`, so the boundary row is
3090            // re-served) is the true completion signal: no further request
3091            // can make progress.
3092            //
3093            // Known residual (gate LOW-B, documented not fixed): the
3094            // cursor is the bare GASP `since` score, so a tie group of
3095            // IDENTICAL scores at least as large as the responder's page
3096            // would end the run with that group's tail unreached (break on
3097            // non-advance — a silent gap, never a spin). A compound
3098            // (score, rowid) cursor would need a wire change to `since`,
3099            // which is out of bounds; unconstructible in practice — see
3100            // the responder-side note at
3101            // `D1Storage::find_utxos_for_topic`.
3102            if self.last_interaction == cursor_before_page {
3103                let settled = min_failed_score.is_none()
3104                    && response.utxo_list.iter().all(|u| {
3105                        shared_outpoints.contains(&format!("{}.{}", u.txid, u.output_index))
3106                    });
3107                if self.request_stop.is_some() && settled && !response.utxo_list.is_empty() {
3108                    // Inclusive peers repeat the boundary. Confirm an empty page above
3109                    // it before claiming tip, without ever storing the probe cursor.
3110                    // A clamped boundary can conceal later rows: retain our cursor
3111                    // and report no progress when the probe finds any.
3112                    if !self.request_stopped() {
3113                        if let Some(since) = self.last_interaction.checked_add(1) {
3114                            let probe = self
3115                                .remote
3116                                .get_initial_response(&GASPInitialRequest {
3117                                    version: GASP_VERSION,
3118                                    since,
3119                                    limit,
3120                                })
3121                                .await?;
3122                            self.page.pages += 1;
3123                            self.page.reached_tip = probe.utxo_list.is_empty();
3124                        }
3125                    }
3126                } else {
3127                    self.page.reached_tip = settled;
3128                }
3129                break;
3130            }
3131            self.page.advanced = true;
3132            if self.request_stop.is_some() {
3133                break;
3134            }
3135        }
3136
3137        // A page left mid-way (the request's allowance) is written now.
3138        self.close_page().await?;
3139
3140        // Gap-guard: if any graph ingest failed transiently, cap the cursor
3141        // strictly below the lowest failed score so the next sync re-pulls it
3142        // (the remote serves `score >= since`). Floored at `initial_interaction`
3143        // so the cursor never regresses — a failure within the already-synced
3144        // range is not a new gap and must not rewind the cursor.
3145        self.completed_cursor =
3146            Self::cursor_for_completed(&completed_scores, min_failed_score, initial_interaction);
3147        if let Some(failed) = min_failed_score {
3148            if self.last_interaction > self.completed_cursor {
3149                warn!(
3150                    "{} Capping cursor {} -> {} (transient ingest failure at score {}); next sync will re-pull",
3151                    self.log_prefix, self.last_interaction, self.completed_cursor, failed
3152                );
3153            }
3154        }
3155        self.last_interaction = self.completed_cursor;
3156
3157        // A saved graph can now lie below the peer cursor. Absence from a
3158        // page is not evidence that its work may be deleted.
3159
3160        // Bidirectional: push our UTXOs to remote
3161        if !self.unidirectional {
3162            // Find local UTXOs the remote doesn't have
3163            for utxo in &local_utxos {
3164                let outpoint = format!("{}.{}", utxo.txid, utxo.output_index);
3165                if !shared_outpoints.contains(&outpoint) {
3166                    match self.push_utxo(utxo).await {
3167                        Ok(()) => {}
3168                        Err(e) => {
3169                            warn!("{} Error pushing UTXO {}: {}", self.log_prefix, outpoint, e);
3170                        }
3171                    }
3172                }
3173            }
3174        }
3175
3176        info!("{} Sync completed!", self.log_prefix);
3177        Ok(())
3178    }
3179
3180    /// Write the open page's admissions (engine 0.2.2). A batch that fails
3181    /// admitted nothing of the page: the cursor falls back to the page's
3182    /// floor and the sync fails, so the next run is served the page again.
3183    async fn close_page(&mut self) -> Result<(), GASPError> {
3184        let Some(floor) = self.page.floor.take() else {
3185            return Ok(());
3186        };
3187        if let Err(e) = self.storage.close_page().await {
3188            warn!("{} {}", self.log_prefix, e);
3189            self.completed_cursor = self.completed_cursor.min(floor);
3190            self.last_interaction = self.completed_cursor;
3191            return Err(e);
3192        }
3193        Ok(())
3194    }
3195
3196    /// The cursor safe before the page whose writes are still queued, if a
3197    /// page was open when `sync` last stopped (engine 0.2.2): its future was
3198    /// dropped mid-page. The caller writes the queue or, failing that, keeps
3199    /// the cursor at this floor.
3200    pub fn page_floor(&self) -> Option<u64> {
3201        self.page.floor
3202    }
3203
3204    /// Request a graph or resume its durable record. Saved holds are safe
3205    /// to pass because the next run resumes keys independently of pagination.
3206    /// Empty deadline retries hold the cursor until the stall threshold.
3207    /// All resumed graphs share one allowance; fresh graphs retain theirs.
3208    async fn ingest_utxo(&self, utxo: &GASPOutput, outpoint: &str) -> Result<Ingested, GASPError> {
3209        if self.graph_budget.is_some() {
3210            crate::meter::root_scope(self.ingest_utxo_inner(utxo, outpoint)).await
3211        } else {
3212            self.ingest_utxo_inner(utxo, outpoint).await
3213        }
3214    }
3215
3216    async fn ingest_utxo_inner(
3217        &self,
3218        utxo: &GASPOutput,
3219        outpoint: &str,
3220    ) -> Result<Ingested, GASPError> {
3221        self.last_fault.set(None);
3222        self.resume_idle.set(None);
3223        self.pass_saved.set(false);
3224        if self.expired.borrow().contains(outpoint) {
3225            if self.deferred.borrow().contains(outpoint) {
3226                let record = self.storage.get_deferred_graph(outpoint).await?;
3227                if self.sweep_expired_record(record.as_ref()).await? {
3228                    return Ok(Ingested::Expired);
3229                }
3230            }
3231            return self.refuse_expired(outpoint).await;
3232        }
3233        debug!("{} Requesting node for {}", self.log_prefix, outpoint);
3234        self.graphs_attempted.set(self.graphs_attempted.get() + 1);
3235        let Some(budget) = &self.graph_budget else {
3236            *self.walk.borrow_mut() =
3237                Some(Self::fresh_walk(utxo.score as u64, outpoint, PassCap::NONE));
3238            crate::meter::walk_output();
3239            let walked = self.walk_graph(None).await;
3240            if matches!(walked, Ok(WalkEnd::Expired)) {
3241                return self.refuse_expired(outpoint).await;
3242            }
3243            let walk = self.walk.borrow_mut().take();
3244            walked?;
3245            self.complete_graph(&Self::graph_id_of(walk.as_ref(), outpoint))
3246                .await?;
3247            return Ok(Ingested::Completed);
3248        };
3249
3250        if !self.deferred.borrow_mut().remove(outpoint) {
3251            let mut deadline = latched((budget.deadline)());
3252            return self
3253                .ingest_budgeted(utxo, outpoint, &mut deadline, budget.pass(), false)
3254                .await;
3255        }
3256        let mut deadline = self
3257            .resume_deadline
3258            .borrow_mut()
3259            .take()
3260            .unwrap_or_else(|| latched((budget.deadline)()));
3261        let cap = self.resume_left.get();
3262        if cap.spent() || Self::due(&mut deadline).await {
3263            *self.resume_deadline.borrow_mut() = Some(deadline);
3264            let record = self.storage.get_deferred_graph(outpoint).await?;
3265            self.read_record(record.as_ref());
3266            if self.sweep_expired_record(record.as_ref()).await? {
3267                return Ok(Ingested::Expired);
3268            }
3269            if record
3270                .as_ref()
3271                .is_some_and(|r| r.waits_idle(Self::now_secs()))
3272            {
3273                return Ok(self.wait_idle(outpoint));
3274            }
3275            // An unanchorable root inside its backoff would not be walked
3276            // anyway: not held back, and no attempt (engine 0.1.11).
3277            if record.as_ref().is_some_and(|r| {
3278                r.is_dead() || (r.is_unanchorable() && Self::now_secs() < r.retry_at)
3279            }) {
3280                self.graphs_attempted
3281                    .set(self.graphs_attempted.get().saturating_sub(1));
3282                return Ok(Ingested::Unanchorable);
3283            }
3284            self.stats.borrow_mut().held_back += 1;
3285            info!(
3286                "{} Deferred graph {} held back: the resumes of this pass spent their budget (bsv-low #555)",
3287                self.log_prefix, outpoint
3288            );
3289            let Some(mut record) = record else {
3290                return Ok(Ingested::Retry);
3291            };
3292            if record.reason == "retry:deadline" {
3293                return Ok(Ingested::Retry);
3294            }
3295            // A fault streak not yet at its threshold holds the cursor; one
3296            // not walked this run is not a faulted run (engine 0.1.9).
3297            if record.is_struck() && !record.cursor_passable {
3298                return Ok(Ingested::Retry);
3299            }
3300            if !record.cursor_passable {
3301                record.cursor_passable = true;
3302                if self.storage.save_deferred_graph(&record).await? != DeferredGraphSave::Saved {
3303                    return Ok(Ingested::Retry);
3304                }
3305            }
3306            return Ok(Ingested::HeldBack);
3307        }
3308        let before = (
3309            self.calls_made.get(),
3310            self.bytes_fetched.get(),
3311            self.nodes_appended.get(),
3312        );
3313        let ingested = self
3314            .ingest_budgeted(utxo, outpoint, &mut deadline, cap, true)
3315            .await;
3316        // Engine 0.1.10: a held record's resume that asked the peer,
3317        // appended no node and did not finish counts one more idle resume.
3318        // Its own save counted it; any other ending counts it here (engine
3319        // 0.1.11): a drop that saved the record again as a fresh obligation
3320        // (`drop_record`) or an error that left it as it was. What the pass
3321        // KEPT is read from the record, not from the nodes it appended: on
3322        // 0.1.10 a root appended and then thrown away with the drop (fact 3
3323        // of the 0.1.11 brief: the courier exhausted on its parent) read as
3324        // progress, and the count never fired. A record a strike counted is
3325        // left to the strike.
3326        if let Some((prior, held)) = self.resume_idle.take() {
3327            if self.calls_made.get() > before.0
3328                && !self.pass_saved.get()
3329                && !matches!(ingested, Ok(Ingested::Completed | Ingested::Expired))
3330            {
3331                self.idle_resume(outpoint, prior, held).await;
3332            }
3333        }
3334        let narrowed = |spent: u64| u32::try_from(spent).unwrap_or(u32::MAX);
3335        self.resume_left.set(PassCap {
3336            calls: cap
3337                .calls
3338                .saturating_sub(narrowed(self.calls_made.get() - before.0)),
3339            bytes: cap
3340                .bytes
3341                .saturating_sub(self.bytes_fetched.get() - before.1),
3342            nodes: cap
3343                .nodes
3344                .saturating_sub(narrowed(self.nodes_appended.get() - before.2)),
3345        });
3346        *self.resume_deadline.borrow_mut() = Some(deadline);
3347        ingested
3348    }
3349
3350    /// A held record not resumed: it waits out the long backoff of its idle
3351    /// resumes (engine 0.1.10). Not asked of the peer: not an attempt for
3352    /// the yieldless streak, as an unanchorable root inside its backoff.
3353    fn wait_idle(&self, outpoint: &str) -> Ingested {
3354        self.graphs_attempted
3355            .set(self.graphs_attempted.get().saturating_sub(1));
3356        self.stats.borrow_mut().waiting += 1;
3357        debug!(
3358            "{} Deferred graph {} waits out its idle backoff (engine 0.1.10)",
3359            self.log_prefix, outpoint
3360        );
3361        Ingested::Waiting
3362    }
3363
3364    /// Count one more idle resume on the held record of `outpoint` after a
3365    /// pass that did not save it (engine 0.1.10): `prior` is what it counted
3366    /// when its pass read it, so a record the pass dropped and saved again as
3367    /// a fresh obligation (`drop_record`) counts on from there. At
3368    /// [`IDLE_RESUMES_BEFORE_BACKOFF`] it goes to the long backoff. A record
3369    /// gone, a fault record, one that holds the cursor, or one holding more
3370    /// nodes than the `held` its pass read (it kept progress) is left alone.
3371    async fn idle_resume(&self, outpoint: &str, prior: u32, held: usize) {
3372        let Ok(Some(mut record)) = self.storage.get_deferred_graph(outpoint).await else {
3373            return;
3374        };
3375        if record.is_struck() || !record.cursor_passable || record.nodes.len() > held {
3376            return;
3377        }
3378        record.idle_resumes = prior.saturating_add(1);
3379        let backoff = record.idle_resumes >= IDLE_RESUMES_BEFORE_BACKOFF;
3380        if backoff {
3381            record.retry_at = Self::now_secs() + IDLE_RESUME_RETRY_SECS;
3382        }
3383        let saved = self.storage.save_deferred_graph(&record).await;
3384        if let Ok(DeferredGraphSave::Saved) = saved {
3385            self.stats.borrow_mut().idle_counted += 1;
3386        }
3387        match saved {
3388            Ok(DeferredGraphSave::Saved) if backoff => {
3389                self.stats.borrow_mut().backed_off += 1;
3390                info!(
3391                    "{} Deferred graph {} resumed {} passes in a row with no node appended: not resumed for {} s (engine 0.1.10)",
3392                    self.log_prefix, outpoint, record.idle_resumes, IDLE_RESUME_RETRY_SECS
3393                );
3394            }
3395            Ok(_) => {}
3396            Err(e) => warn!(
3397                "{} Could not count an idle resume of deferred graph {}: {}",
3398                self.log_prefix, outpoint, e
3399            ),
3400        }
3401    }
3402
3403    /// Whether `deadline` has fallen due (polled once, with the task's own
3404    /// waker).
3405    async fn due(deadline: &mut crate::engine::SleepFuture) -> bool {
3406        std::future::poll_fn(|cx| std::task::Poll::Ready(deadline.as_mut().poll(cx).is_ready()))
3407            .await
3408    }
3409
3410    /// A walk from the root `outpoint`, nothing fetched yet.
3411    fn fresh_walk(score: u64, outpoint: &str, cap: PassCap) -> Walk {
3412        Walk {
3413            record: DeferredGraph {
3414                peer: String::new(),
3415                topic: String::new(),
3416                outpoint: outpoint.to_string(),
3417                score,
3418                nodes: Vec::new(),
3419                pending: vec![PendingInput {
3420                    outpoint: outpoint.to_string(),
3421                    graph_id: outpoint.to_string(),
3422                    metadata: true,
3423                    spent_by: None,
3424                    parent_proven: false,
3425                }],
3426                calls: 0,
3427                passes: 0,
3428                reason: String::new(),
3429                configured: false,
3430                idle_faults: 0,
3431                cursor_passable: false,
3432                faults: 0,
3433                retry_at: 0,
3434                idle_resumes: 0,
3435                unanchorable_retries: 0,
3436                expiry: None,
3437            },
3438            seen: std::collections::HashSet::new(),
3439            calls_this_pass: 0,
3440            bytes_this_pass: 0,
3441            nodes_this_pass: 0,
3442            resumed: false,
3443            cap,
3444            nodes_at_start: 0,
3445            counted: false,
3446            gone_on: false,
3447            half: None,
3448            struck: None,
3449        }
3450    }
3451
3452    /// One pass of a graph's walk under the per-graph budget: `deadline`,
3453    /// and `cap` (calls, bytes, nodes). `from_record`: a record of it is held (its key was
3454    /// loaded); it is read now.
3455    async fn ingest_budgeted(
3456        &self,
3457        utxo: &GASPOutput,
3458        outpoint: &str,
3459        deadline: &mut crate::engine::SleepFuture,
3460        cap: PassCap,
3461        from_record: bool,
3462    ) -> Result<Ingested, GASPError> {
3463        let score = utxo.score as u64;
3464        // A read fault fails the UTXO and keeps the record for the next sync.
3465        let mut record = if from_record {
3466            let record = self.storage.get_deferred_graph(outpoint).await?;
3467            self.read_record(record.as_ref());
3468            if record.is_none() {
3469                // Deleted since the keys were read (the worker's sweep).
3470                self.held_records
3471                    .set(self.held_records.get().saturating_sub(1));
3472            }
3473            record
3474        } else {
3475            None
3476        };
3477        if self.sweep_expired_record(record.as_ref()).await? {
3478            return Ok(Ingested::Expired);
3479        }
3480        if record
3481            .as_ref()
3482            .is_some_and(|r| (r.is_unanchorable() || r.is_dead()) && r.score != score)
3483        {
3484            self.storage.delete_deferred_graph(outpoint).await?;
3485            self.forget_record(outpoint);
3486            record = None;
3487        }
3488        if let Some(record) = record.as_ref().filter(|r| r.is_struck()) {
3489            return self
3490                .rewalk_struck(utxo, outpoint, record.clone(), deadline, cap)
3491                .await;
3492        }
3493        if let Some(record) = &record {
3494            if let [root, ..] = record.nodes.as_slice() {
3495                if self.root_expired(&root.node, outpoint)? {
3496                    self.deferred.borrow_mut().insert(outpoint.to_string());
3497                    return self.refuse_expired(outpoint).await;
3498                }
3499            }
3500            // Engine 0.1.10: a held record past its idle resumes waits out
3501            // the long backoff (an expired root it holds was refused above,
3502            // with no request); else its pass counts on from what it holds.
3503            if record.cursor_passable {
3504                if record.waits_idle(Self::now_secs()) {
3505                    return Ok(self.wait_idle(outpoint));
3506                }
3507                self.resume_idle
3508                    .set(Some((record.idle_resumes, record.nodes.len())));
3509            }
3510        }
3511        let walk = match record {
3512            None => {
3513                let mut walk = Self::fresh_walk(score, outpoint, cap);
3514                // D2-L1: the half-budget clock starts with the walk (polled
3515                // once now, so a timer that starts on its first poll runs).
3516                if let Some(half) = self
3517                    .graph_budget
3518                    .as_ref()
3519                    .and_then(|b| b.half_deadline.as_ref())
3520                {
3521                    let mut half = latched(half());
3522                    let _ = Self::due(&mut half).await;
3523                    walk.half = Some(half);
3524                }
3525                walk
3526            }
3527            Some(record) if record.passes >= DEFERRED_GRAPH_MAX_PASSES => {
3528                let passes = record.passes;
3529                self.drop_record(outpoint, DropReason::MaxPasses).await;
3530                return Err(GASPError::Other(format!(
3531                    "deferred graph {outpoint} dropped after {passes} passes (bsv-low #555)"
3532                )));
3533            }
3534            Some(record) => {
3535                self.stats.borrow_mut().resumed += 1;
3536                info!(
3537                    "{} Resuming deferred graph {} (bsv-low #555): {} nodes, {} pending, {} calls over {} passes",
3538                    self.log_prefix,
3539                    outpoint,
3540                    record.nodes.len(),
3541                    record.pending.len(),
3542                    record.calls,
3543                    record.passes
3544                );
3545                Walk {
3546                    seen: record
3547                        .nodes
3548                        .iter()
3549                        .filter_map(|w| Self::node_id(&w.node))
3550                        .collect(),
3551                    nodes_at_start: record.nodes.len(),
3552                    record,
3553                    calls_this_pass: 0,
3554                    bytes_this_pass: 0,
3555                    nodes_this_pass: 0,
3556                    resumed: true,
3557                    cap,
3558                    counted: false,
3559                    gone_on: false,
3560                    half: None,
3561                    struck: None,
3562                }
3563            }
3564        };
3565        let resumed = walk.resumed;
3566        let root_unproven = walk
3567            .record
3568            .nodes
3569            .first()
3570            .is_some_and(|w| w.node.proof.is_none());
3571        crate::meter::walk_output();
3572        *self.walk.borrow_mut() = Some(walk);
3573
3574        let ended = async {
3575            if resumed && root_unproven {
3576                // The root is asked again (one call): once its block lands
3577                // the peer serves it PROVEN, and the walk from it is shorter
3578                // than the unproven ancestry the record is still walking.
3579                let root = PendingInput {
3580                    outpoint: outpoint.to_string(),
3581                    graph_id: outpoint.to_string(),
3582                    metadata: true,
3583                    spent_by: None,
3584                    parent_proven: false,
3585                };
3586                match race_or_deadline(self.fetch_root(&root), deadline.as_mut()).await {
3587                    None => return Ok(WalkEnd::Deferred("time")),
3588                    Some(Ok(None)) => return Ok(WalkEnd::Expired),
3589                    Some(Ok(Some(node))) if node.proof.is_some() => {
3590                        // The walk is fresh, the PASS is not: what it spent
3591                        // of every counted limb goes with it (the re-ask
3592                        // appends nothing, so the nodes are 0 today; carried
3593                        // all the same, so a limb is never forgotten here).
3594                        let (carried, carried_bytes, carried_nodes) =
3595                            self.walk.borrow().as_ref().map_or((0, 0, 0), |w| {
3596                                (w.calls_this_pass, w.bytes_this_pass, w.nodes_this_pass)
3597                            });
3598                        let reserved = self
3599                            .walk
3600                            .borrow()
3601                            .as_ref()
3602                            .is_some_and(|w| w.record.cursor_passable);
3603                        self.drop_record(outpoint, DropReason::RootProven).await;
3604                        let mut restarted = Self::fresh_walk(score, outpoint, cap);
3605                        restarted.resumed = reserved;
3606                        restarted.calls_this_pass = carried;
3607                        restarted.bytes_this_pass = carried_bytes;
3608                        restarted.nodes_this_pass = carried_nodes;
3609                        restarted.record.calls = u64::from(carried);
3610                        *self.walk.borrow_mut() = Some(restarted);
3611                    }
3612                    // Unchanged, or not answered: the record goes on.
3613                    Some(_) => {}
3614                }
3615            }
3616            self.append_walked().await?;
3617            self.walk_graph(Some(&mut *deadline)).await
3618        }
3619        .await;
3620        self.finish_pass(outpoint, ended, deadline).await
3621    }
3622
3623    /// Append the walk's fetched nodes to the graph (a resume, or a walk
3624    /// that goes on after its graph was discarded).
3625    async fn append_walked(&self) -> Result<(), GASPError> {
3626        let nodes: Vec<WalkedNode> = self
3627            .walk
3628            .borrow()
3629            .as_ref()
3630            .map(|w| w.record.nodes.clone())
3631            .unwrap_or_default();
3632        for walked in &nodes {
3633            self.storage
3634                .append_to_graph(&walked.node, walked.spent_by.as_deref())
3635                .await?;
3636        }
3637        Ok(())
3638    }
3639
3640    /// The end of a budgeted pass: complete the graph, defer it, or fail
3641    /// its UTXO. A call/size cut whose record cannot be kept may continue
3642    /// within the same root deadline (L4). Time and courier limits stop it
3643    /// even when the storage refuses the save; the cursor retains its gap.
3644    async fn finish_pass(
3645        &self,
3646        outpoint: &str,
3647        mut ended: Result<WalkEnd, GASPError>,
3648        deadline: &mut crate::engine::SleepFuture,
3649    ) -> Result<Ingested, GASPError> {
3650        loop {
3651            let graph_id = Self::graph_id_of(self.walk.borrow().as_ref(), outpoint);
3652            // Whether a record of this graph is still held: not after a
3653            // `root_proven` restart, whose walk is fresh.
3654            let resumed = self.walk.borrow().as_ref().is_some_and(|w| w.resumed);
3655            let (reason, completed) = match ended {
3656                Ok(WalkEnd::Expired) => return self.refuse_expired(outpoint).await,
3657                Ok(WalkEnd::Done) => match self.complete_graph(&graph_id).await {
3658                    Ok(true) => return Ok(Ingested::Completed),
3659                    Ok(false) => {
3660                        // Refused by the anchor check: a verdict, the cursor
3661                        // moves, nothing to resume. A root's fault record
3662                        // goes with it (engine 0.1.9).
3663                        let struck = self
3664                            .walk
3665                            .borrow_mut()
3666                            .take()
3667                            .is_some_and(|w| w.struck.is_some());
3668                        if resumed || struck {
3669                            if struck {
3670                                self.unanchorable_cleared
3671                                    .set(self.unanchorable_cleared.get() + 1);
3672                            }
3673                            self.drop_record(outpoint, DropReason::Refused).await;
3674                        }
3675                        return Ok(Ingested::Completed);
3676                    }
3677                    // `complete_graph` discarded the graph already.
3678                    Err(GASPError::AnchorUnavailable(_)) => ("anchor_unavailable", true),
3679                    // The same hold, for a walk past the budget (E592 fold).
3680                    Err(GASPError::AnchorWalkHeld(_)) => ("anchor_walk_held", true),
3681                    // A finalize fault: the graph is still in hand.
3682                    Err(_) => {
3683                        let _ = self.storage.discard_graph(&graph_id).await;
3684                        ("fault", true)
3685                    }
3686                },
3687                Ok(WalkEnd::Deferred(reason)) => (reason, false),
3688                Err(e @ (GASPError::NodeNotFound(_) | GASPError::DefinitiveNotFound(_))) => {
3689                    let _ = self.storage.discard_graph(&graph_id).await;
3690                    let walk = self.walk.borrow_mut().take();
3691                    if resumed {
3692                        // Engine 0.1.11: a fault of the graph is a strike on
3693                        // the record's own slot, as for a fresh root.
3694                        if let Some(struck) = self.strike_resumed(outpoint, walk.as_ref(), &e).await
3695                        {
3696                            return if struck.passable() {
3697                                Ok(Ingested::Unanchorable)
3698                            } else {
3699                                Err(e)
3700                            };
3701                        }
3702                        self.drop_record(outpoint, DropReason::NotHeld).await;
3703                    }
3704                    return Err(e);
3705                }
3706                // A walk ERROR on a FRESH walk keeps no record (the delta
3707                // fold's D-M1): a fault is not a budget cut, and before #555
3708                // it failed the UTXO. A record then costs a peer a budget
3709                // cut (`calls`, `time`, `peer_deadline`), never a quick 5xx:
3710                // a stranger who answered each input with a 503 kept a
3711                // one-node record per UTXO, 16 per (peer, topic) in one tick
3712                // with no time spent, and filled the worker's ceiling. A
3713                // RESUMED walk keeps its record (its progress was paid for).
3714                //
3715                // Unless it PAID (the delta-2 fold's D2-L1): a fresh walk
3716                // that faults after half its per-graph calls or half its
3717                // time keeps its record as a budget cut would, so an honest
3718                // flaky peer's deep graph is not walked from its root on
3719                // every tick ((1-p)^100 a clean pass at a fault rate p).
3720                Err(e) if !resumed && !self.walk_paid().await => {
3721                    warn!(
3722                        "{} Walk of {} faulted: no record, the UTXO fails as before #555: {}",
3723                        self.log_prefix, outpoint, e
3724                    );
3725                    let _ = self.storage.discard_graph(&graph_id).await;
3726                    self.walk.borrow_mut().take();
3727                    return Err(e);
3728                }
3729                Err(e) => {
3730                    // D2-M2: a RESUMED pass that faulted having appended
3731                    // nothing is idle; at DEFERRED_GRAPH_MAX_IDLE_FAULTS in a
3732                    // row the record goes and the UTXO fails.
3733                    let idle = {
3734                        let mut guard = self.walk.borrow_mut();
3735                        guard.as_mut().map_or(0, |w| {
3736                            if w.resumed && w.record.nodes.len() <= w.nodes_at_start {
3737                                w.record.idle_faults += 1;
3738                            }
3739                            w.record.idle_faults
3740                        })
3741                    };
3742                    if idle >= DEFERRED_GRAPH_MAX_IDLE_FAULTS {
3743                        warn!(
3744                            "{} Walk of {} faulted with nothing appended {} passes in a row: dropped: {}",
3745                            self.log_prefix, outpoint, idle, e
3746                        );
3747                        let _ = self.storage.discard_graph(&graph_id).await;
3748                        let walk = self.walk.borrow_mut().take();
3749                        // Engine 0.1.11: dropped by a fault of the graph, a
3750                        // strike on the record's own slot.
3751                        if let Some(struck) = self.strike_resumed(outpoint, walk.as_ref(), &e).await
3752                        {
3753                            return if struck.passable() {
3754                                Ok(Ingested::Unanchorable)
3755                            } else {
3756                                Err(e)
3757                            };
3758                        }
3759                        self.drop_record(outpoint, DropReason::IdleFaults).await;
3760                        return Err(e);
3761                    }
3762                    warn!(
3763                        "{} Walk of {} faulted, deferred with its progress: {}",
3764                        self.log_prefix, outpoint, e
3765                    );
3766                    let _ = self.storage.discard_graph(&graph_id).await;
3767                    ("fault", false)
3768                }
3769            };
3770            // Call and size cuts may go on if no record fits (L4), with
3771            // the original deadline still active. Time and courier cuts
3772            // stop this root even if its save is refused.
3773            let budget_cut = matches!(reason, "calls" | "bytes" | "nodes");
3774            let (why, mut walk) = match self.save_walk(reason, completed).await {
3775                Saved::Retry => {
3776                    let _ = self.storage.discard_graph(&graph_id).await;
3777                    return Ok(Ingested::Retry);
3778                }
3779                Saved::Yes => {
3780                    let _ = self.storage.discard_graph(&graph_id).await;
3781                    return Ok(Ingested::Deferred);
3782                }
3783                Saved::WentOn => {
3784                    let _ = self.storage.discard_graph(&graph_id).await;
3785                    return Err(GASPError::Other(format!(
3786                        "graph {outpoint} went on and did not complete (bsv-low #555)"
3787                    )));
3788                }
3789                // A store FAULT at a deferral goes on as the other bounds do
3790                // (the delta fold's D-L2; counted `store_fault` by
3791                // `save_walk`): it failed the UTXO, a transient D1 fault
3792                // failing a graph the per-peer budget would have completed.
3793                Saved::Fault(e, walk) => {
3794                    if !budget_cut {
3795                        let _ = self.storage.discard_graph(&graph_id).await;
3796                        return Err(e);
3797                    }
3798                    (DropReason::StoreFault, walk)
3799                }
3800                // No room for a RESUMED walk (bsv-low #585): its held record
3801                // stays, so what this pass adds is lost and nothing else. A
3802                // budget cut goes on as the other bounds do; any other end
3803                // fails the UTXO and the next pass resumes from the record.
3804                Saved::Ceiling(walk) => {
3805                    if !budget_cut {
3806                        let _ = self.storage.discard_graph(&graph_id).await;
3807                        return Err(GASPError::Other(format!(
3808                            "deferred graph {outpoint} found no room, its record kept (bsv-low #585)"
3809                        )));
3810                    }
3811                    (DropReason::TooMany, walk)
3812                }
3813                Saved::Unkept(why, mut walk) => {
3814                    self.unkept(why, &mut walk).await;
3815                    if !(budget_cut && why == DropReason::TooMany) {
3816                        let _ = self.storage.discard_graph(&graph_id).await;
3817                        return Err(GASPError::Other(format!(
3818                            "deferred graph {outpoint} not kept: {} (bsv-low #555)",
3819                            why.as_str()
3820                        )));
3821                    }
3822                    (why, walk)
3823                }
3824            };
3825            info!(
3826                "{} Graph {} cannot be kept ({}): its walk goes on with the root deadline (bsv-low #555)",
3827                self.log_prefix,
3828                outpoint,
3829                why.as_str()
3830            );
3831            walk.cap = PassCap::NONE;
3832            walk.gone_on = true;
3833            *self.walk.borrow_mut() = Some(*walk);
3834            // The graph in hand was never discarded: walk on.
3835            ended = self.walk_graph(Some(&mut *deadline)).await;
3836        }
3837    }
3838
3839    /// After a COMPLETED graph's hook (bsv-low #555): a graph that landed
3840    /// deletes its record (a resumed one converged); one whose finalize did
3841    /// not land keeps its walk as a record with nothing pending, so the next
3842    /// pass completes it again without walking it again. No-op without a
3843    /// per-graph budget.
3844    async fn settle_completed(&self, landed: bool) {
3845        if self.graph_budget.is_none() {
3846            self.walk.borrow_mut().take();
3847            return;
3848        }
3849        if landed {
3850            let Some(walk) = self.walk.borrow_mut().take() else {
3851                return;
3852            };
3853            if walk.struck.is_some() {
3854                // A root walked again after faulted runs landed (engine
3855                // 0.1.9): admitted as any root, its record cleared.
3856                if let Err(e) = self
3857                    .storage
3858                    .delete_deferred_graph(&walk.record.outpoint)
3859                    .await
3860                {
3861                    warn!(
3862                        "{} Could not delete the fault record of landed root {}: {}",
3863                        self.log_prefix, walk.record.outpoint, e
3864                    );
3865                }
3866                self.forget_record(&walk.record.outpoint);
3867                self.unanchorable_cleared
3868                    .set(self.unanchorable_cleared.get() + 1);
3869                info!(
3870                    "{} Root {} LANDED after {} faulted runs: its record cleared (engine 0.1.9)",
3871                    self.log_prefix,
3872                    walk.record.outpoint,
3873                    walk.struck.as_ref().map_or(0, |r| r.faults)
3874                );
3875            } else if walk.resumed {
3876                if let Err(e) = self
3877                    .storage
3878                    .delete_deferred_graph(&walk.record.outpoint)
3879                    .await
3880                {
3881                    warn!(
3882                        "{} Could not delete the record of converged graph {}: {}",
3883                        self.log_prefix, walk.record.outpoint, e
3884                    );
3885                }
3886                self.forget_record(&walk.record.outpoint);
3887                self.stats.borrow_mut().converged += 1;
3888                self.resumed_with_progress
3889                    .set(self.resumed_with_progress.get() + 1);
3890                info!(
3891                    "{} Deferred graph {} CONVERGED after {} passes, {} calls, {} nodes (bsv-low #555)",
3892                    self.log_prefix,
3893                    walk.record.outpoint,
3894                    walk.record.passes + 1,
3895                    walk.record.calls,
3896                    walk.record.nodes.len()
3897                );
3898            }
3899        } else if let Saved::Unkept(why, mut walk) = self.save_walk("not_landed", true).await {
3900            self.unkept(why, &mut walk).await;
3901        }
3902    }
3903
3904    /// Save the walk in hand as its graph's record (bsv-low #555), counting
3905    /// a deferral; or hand it back UNKEPT with the reason (past a bound, or
3906    /// holding no node), or answer the storage's fault with the walk (a call
3907    /// or size cut may go on within its deadline, D-L2; any other fails its
3908    /// UTXO). A walk that already went on is neither saved nor counted again
3909    /// (D-L1). Counts the pass progressed or stalled (the lens fold's H1),
3910    /// once per pass.
3911    async fn save_walk(&self, reason: &'static str, completed: bool) -> Saved {
3912        let Some(mut walk) = self.walk.borrow_mut().take() else {
3913            // Nothing in hand: nothing to save.
3914            return Saved::Yes;
3915        };
3916        if walk.gone_on {
3917            // Its drop was counted when it went on (D-L1).
3918            return Saved::WentOn;
3919        }
3920        if !walk.counted {
3921            walk.counted = true;
3922            let mut stats = self.stats.borrow_mut();
3923            if completed || walk.record.nodes.len() > walk.nodes_at_start {
3924                stats.progressed += 1;
3925            } else {
3926                stats.stalled += 1;
3927            }
3928        }
3929        let was_held = walk.record.cursor_passable;
3930        let deadline_cut = matches!(reason, "time" | "peer_deadline");
3931        let deadline_streak = was_held || walk.record.reason == "retry:deadline";
3932        if walk.record.nodes.is_empty() && !deadline_streak {
3933            walk.record.passes = 0;
3934        }
3935        walk.record.passes += 1;
3936        // Only the graph's OWN deadline earns the stall allowance. The
3937        // per-peer deadline ends the slice, not the graph: what it cut is
3938        // held at once and the cursor passes it.
3939        let retry = walk.record.nodes.is_empty()
3940            && reason == "time"
3941            && !was_held
3942            && walk.record.passes < DEADLINE_STALL_DEFERRAL_RUNS;
3943        walk.record.cursor_passable = !retry;
3944        walk.record.reason = if retry {
3945            "retry:deadline".into()
3946        } else {
3947            reason.into()
3948        };
3949        if walk.record.nodes.len() > walk.nodes_at_start {
3950            walk.record.idle_faults = 0;
3951            // A pass that appends a node resets the idle resumes (0.1.10).
3952            walk.record.idle_resumes = 0;
3953            walk.record.retry_at = 0;
3954        }
3955        // Engine 0.1.10: a RESUMED pass of a held record that asked the peer
3956        // and appended nothing counts one idle resume in this save; at the
3957        // bound the record waits out the long backoff.
3958        let idle_now = walk.resumed
3959            && was_held
3960            && walk.struck.is_none()
3961            && !completed
3962            && walk.calls_this_pass > 0
3963            && walk.record.nodes.len() <= walk.nodes_at_start;
3964        let backing_off = if idle_now {
3965            walk.record.idle_resumes = walk.record.idle_resumes.saturating_add(1);
3966            let due = walk.record.idle_resumes >= IDLE_RESUMES_BEFORE_BACKOFF;
3967            if due {
3968                walk.record.retry_at = Self::now_secs() + IDLE_RESUME_RETRY_SECS;
3969            }
3970            due
3971        } else {
3972            false
3973        };
3974        // Empty walks qualify only after repeated deadline cuts. The small
3975        // retry record uses the same per-peer and global storage ceilings.
3976        // A quick fresh fault still earns no record.
3977        if walk.record.nodes.is_empty() && !deadline_cut && !was_held {
3978            return Saved::Unkept(DropReason::NoProgress, Box::new(walk));
3979        }
3980        // A resumed walk holds its record's deferred slot. A root walked
3981        // again after faulted runs holds a FAULT record (engine 0.1.10): the
3982        // hold its walk earned replaces it only with room among the deferred
3983        // slots, else the walk goes on as any walk past the bound (L4) and
3984        // its fault record stays as it was.
3985        if !walk.resumed && self.held_records.get() >= DEFERRED_GRAPHS_PER_PEER_TOPIC {
3986            return Saved::Unkept(DropReason::TooMany, Box::new(walk));
3987        }
3988        // No bound on a record's size (bsv-low #585): the figure is logged
3989        // and is the storage's to budget.
3990        let bytes = walk.record.byte_size();
3991        let outpoint = walk.record.outpoint.clone();
3992        match self.storage.save_deferred_graph(&walk.record).await {
3993            Ok(DeferredGraphSave::Saved) => {
3994                if walk.resumed {
3995                    self.pass_saved.set(true);
3996                }
3997                if idle_now {
3998                    self.stats.borrow_mut().idle_counted += 1;
3999                }
4000                if !walk.resumed {
4001                    // A fault record replaced is a fault record no more.
4002                    self.fault_records.borrow_mut().remove(&outpoint);
4003                    self.held_records.set(self.held_records.get() + 1);
4004                    if !retry && !walk.record.nodes.is_empty() {
4005                        self.retained.set(self.retained.get() + 1);
4006                    }
4007                } else if walk.record.nodes.len() > walk.nodes_at_start {
4008                    self.resumed_with_progress
4009                        .set(self.resumed_with_progress.get() + 1);
4010                }
4011                if !retry {
4012                    self.stats.borrow_mut().deferred += 1;
4013                }
4014                if backing_off {
4015                    self.stats.borrow_mut().backed_off += 1;
4016                    info!(
4017                        "{} Deferred graph {} resumed {} passes in a row with no node appended: not resumed for {} s (engine 0.1.10)",
4018                        self.log_prefix, outpoint, walk.record.idle_resumes, IDLE_RESUME_RETRY_SECS
4019                    );
4020                }
4021                info!(
4022                    "{} DEFERRED graph {} ({}): {} nodes, {} pending, {} calls ({} this pass), pass {}, {} bytes (bsv-low #555)",
4023                    self.log_prefix,
4024                    outpoint,
4025                    reason,
4026                    walk.record.nodes.len(),
4027                    walk.record.pending.len(),
4028                    walk.record.calls,
4029                    walk.calls_this_pass,
4030                    walk.record.passes,
4031                    bytes
4032                );
4033                if retry {
4034                    Saved::Retry
4035                } else {
4036                    Saved::Yes
4037                }
4038            }
4039            // The storage's ceiling is a budget of room, not a verdict on
4040            // the graph (bsv-low #585): a RESUMED walk keeps the record it
4041            // holds (it was deleted, and the graph walked from its root at
4042            // the next pass); a fresh walk has none to keep.
4043            Ok(DeferredGraphSave::AtCeiling) if walk.resumed => {
4044                self.note_dropped(&outpoint, DropReason::TooMany);
4045                Saved::Ceiling(Box::new(walk))
4046            }
4047            Ok(DeferredGraphSave::AtCeiling) => Saved::Unkept(DropReason::TooMany, Box::new(walk)),
4048            // The save is lost and counted `store_fault`. A RESUMED walk's
4049            // held record is KEPT (the delta-2 lens's D2-N2: it was deleted,
4050            // and one transient D1 fault threw away every earlier pass of a
4051            // walk the per-peer budget did not then finish): the next pass
4052            // resumes from it, losing this pass only; a walk that goes on
4053            // and lands deletes it as a converged one.
4054            Err(e) => {
4055                self.note_dropped(&outpoint, DropReason::StoreFault);
4056                Saved::Fault(e, Box::new(walk))
4057            }
4058        }
4059    }
4060
4061    /// A walk the save did not keep: reset its retained obligation or count
4062    /// the drop. A fresh walk that fetched nothing is only logged.
4063    async fn unkept(&self, why: DropReason, walk: &mut Walk) {
4064        let outpoint = walk.record.outpoint.clone();
4065        if walk.resumed {
4066            self.drop_record(&outpoint, why).await;
4067            walk.resumed = false;
4068        } else if why == DropReason::NoProgress {
4069            warn!(
4070                "{} Walk of {} fetched nothing: not kept, the UTXO fails (bsv-low #555)",
4071                self.log_prefix, outpoint
4072            );
4073        } else {
4074            self.note_dropped(&outpoint, why);
4075        }
4076    }
4077
4078    /// Whether the walk in hand has PAID for a record (the delta-2 fold's
4079    /// D2-L1): half its per-graph calls made in this pass, or its half
4080    /// deadline fallen due. A walk that holds no node never has.
4081    async fn walk_paid(&self) -> bool {
4082        let Some(budget) = &self.graph_budget else {
4083            return false;
4084        };
4085        let mut half = {
4086            let mut guard = self.walk.borrow_mut();
4087            let Some(walk) = guard.as_mut() else {
4088                return false;
4089            };
4090            if walk.record.nodes.is_empty() {
4091                return false;
4092            }
4093            if u64::from(walk.calls_this_pass) * 2 >= u64::from(budget.max_calls) {
4094                return true;
4095            }
4096            match walk.half.take() {
4097                Some(half) => half,
4098                None => return false,
4099            }
4100        };
4101        let paid = Self::due(&mut half).await;
4102        if let Some(walk) = self.walk.borrow_mut().as_mut() {
4103            walk.half = Some(half);
4104        }
4105        paid
4106    }
4107
4108    /// Drop the partial walk, preserving a retry obligation if the cursor may have passed.
4109    async fn drop_record(&self, outpoint: &str, reason: DropReason) {
4110        if !matches!(reason, DropReason::Held | DropReason::Refused) {
4111            let Ok(record) = self.storage.get_deferred_graph(outpoint).await else {
4112                return;
4113            };
4114            if let Some(record) = record {
4115                if record.cursor_passable {
4116                    let mut retry = Self::fresh_walk(record.score, outpoint, PassCap::NONE).record;
4117                    retry.reason = "retry".into();
4118                    retry.cursor_passable = true;
4119                    let _ = self.storage.save_deferred_graph(&retry).await;
4120                    self.note_dropped(outpoint, reason);
4121                    return;
4122                }
4123            }
4124        }
4125        if let Err(e) = self.storage.delete_deferred_graph(outpoint).await {
4126            warn!(
4127                "{} Could not delete the record of deferred graph {}: {}",
4128                self.log_prefix, outpoint, e
4129            );
4130        }
4131        self.forget_record(outpoint);
4132        self.note_dropped(outpoint, reason);
4133    }
4134
4135    fn note_dropped(&self, outpoint: &str, reason: DropReason) {
4136        warn!(
4137            "{} Deferred graph {} DROPPED ({}) (bsv-low #555)",
4138            self.log_prefix,
4139            outpoint,
4140            reason.as_str()
4141        );
4142        self.stats.borrow_mut().dropped.push(DroppedGraph {
4143            outpoint: outpoint.to_string(),
4144            reason,
4145        });
4146    }
4147
4148    /// Save the walk of the graph that was in hand when a deadline dropped
4149    /// `sync` (bsv-low #555): the per-peer deadline (D16) defers it like the
4150    /// per-graph one, so the next pass resumes it instead of walking it
4151    /// again. No-op without a per-graph budget.
4152    ///
4153    /// The graph in flight is HELD at once, whatever it fetched: its root
4154    /// outpoint and partial walk become a durable record (reason
4155    /// `peer_deadline`) the next sync resumes ahead of its pages, so the
4156    /// cursor passes it to the page's last finished score. Before 0.1.8 a
4157    /// graph the deadline met before its root came back was only a retry,
4158    /// and the next run re-read the same page behind it (bsv-low #552:
4159    /// "the graph in flight is lost whole"). A graph the deadline met after
4160    /// its refusal is finished, not held. A graph that cannot be held (no
4161    /// room) stays the cursor's gap and is named in `cursor_held_by`.
4162    pub async fn defer_in_flight(&mut self) {
4163        let in_flight = self.in_flight.take();
4164        if self.graph_budget.is_none() {
4165            return;
4166        }
4167        if let Some((_, outpoint)) = &in_flight {
4168            if self.expired.borrow().contains(outpoint) {
4169                let outpoint = outpoint.clone();
4170                self.walk.borrow_mut().take();
4171                self.completed_cursor = self.cursor_after_in_flight;
4172                self.release_hold(&outpoint);
4173                return;
4174            }
4175        }
4176        // An unanchorable root cut mid-walk stays unanchorable (engine
4177        // 0.1.9): its record is kept, its backoff starts again, the cursor
4178        // passes it as before. A root whose fault streak is still under the
4179        // threshold is held at the deadline as any graph in flight.
4180        let unanchorable = self
4181            .walk
4182            .borrow()
4183            .as_ref()
4184            .and_then(|w| w.struck.as_deref().filter(|r| r.is_unanchorable()).cloned());
4185        if let Some(record) = unanchorable {
4186            self.walk.borrow_mut().take();
4187            let outpoint = record.outpoint.clone();
4188            if let Err(e) = self.keep_unanchorable(&record).await {
4189                warn!(
4190                    "{} Could not keep unanchorable root {} at the deadline: {}",
4191                    self.log_prefix, outpoint, e
4192                );
4193            }
4194            self.completed_cursor = self.cursor_after_in_flight;
4195            self.release_hold(&outpoint);
4196            return;
4197        }
4198        // A deadline during pagination has no graph to save. Preserve the
4199        // completed cursor, including any earlier unsaved gap.
4200        if self.walk.borrow().is_none() {
4201            let Some((score, outpoint)) = in_flight else {
4202                return;
4203            };
4204            *self.walk.borrow_mut() = Some(Self::fresh_walk(score, &outpoint, PassCap::NONE));
4205        }
4206        let outpoint = self
4207            .walk
4208            .borrow()
4209            .as_ref()
4210            .map(|w| w.record.outpoint.clone())
4211            .unwrap_or_default();
4212        match self.save_walk("peer_deadline", false).await {
4213            Saved::Yes => {
4214                self.completed_cursor = self.cursor_after_in_flight;
4215                self.release_hold(&outpoint);
4216            }
4217            Saved::Unkept(why, mut walk) => self.unkept(why, &mut walk).await,
4218            _ => {}
4219        }
4220    }
4221
4222    /// The graph id of the walk in hand: its root's, else the outpoint.
4223    fn graph_id_of(walk: Option<&Walk>, outpoint: &str) -> String {
4224        walk.and_then(|w| w.record.nodes.first())
4225            .map_or_else(|| outpoint.to_string(), |w| w.node.graph_id.clone())
4226    }
4227
4228    /// `txid.outputIndex` of a node, its txid computed from its raw bytes.
4229    fn node_id(node: &GASPNode) -> Option<String> {
4230        bsv_rs::transaction::Transaction::from_hex(&node.raw_tx)
4231            .ok()
4232            .map(|tx| format!("{}.{}", tx.id(), node.output_index))
4233    }
4234
4235    /// Count a node the graph in hand was SERVED (bsv-low #586): the bytes
4236    /// the peer sent for it, which are the bytes a record keeps of it.
4237    fn count_fetched(&self, node: &GASPNode) {
4238        let bytes = (node.raw_tx.len() + node.proof.as_ref().map_or(0, String::len)) as u64;
4239        self.bytes_fetched.set(self.bytes_fetched.get() + bytes);
4240        if let Some(w) = self.walk.borrow_mut().as_mut() {
4241            w.bytes_this_pass += bytes;
4242        }
4243    }
4244
4245    /// Count one call of the graph in hand.
4246    fn count_call(&self) {
4247        self.calls_made.set(self.calls_made.get() + 1);
4248        if let Some(w) = self.walk.borrow_mut().as_mut() {
4249            w.calls_this_pass += 1;
4250            w.record.calls += 1;
4251        }
4252    }
4253
4254    /// Push a local UTXO's graph to the remote.
4255    async fn push_utxo(&self, utxo: &GASPOutput) -> Result<(), GASPError> {
4256        let outpoint = format!("{}.{}", utxo.txid, utxo.output_index);
4257        debug!("{} Hydrating node for {}", self.log_prefix, outpoint);
4258
4259        let node = self
4260            .storage
4261            .hydrate_gasp_node(&outpoint, &utxo.txid, utxo.output_index, true)
4262            .await?;
4263
4264        self.process_outgoing_node(&node, &mut std::collections::HashSet::new())
4265            .await?;
4266
4267        Ok(())
4268    }
4269
4270    fn root_bound(&self, reason: &'static str) -> WalkEnd {
4271        self.last_fault.set(None);
4272        if let Some(walk) = self.walk.borrow().as_ref() {
4273            self.stats
4274                .borrow_mut()
4275                .root_bounds
4276                .push(crate::sync_load::RootBound {
4277                    peer: String::new(),
4278                    outpoint: walk.record.outpoint.clone(),
4279                    reason: reason.into(),
4280                });
4281        }
4282        WalkEnd::Deferred(reason)
4283    }
4284
4285    /// Walk the graph in hand until nothing is pending (an explicit stack,
4286    /// bsv-low #555; before it a recursion, in the same order: an input's
4287    /// whole branch before its next sibling). Each step's result is
4288    /// committed to the walk only once the step has finished, so a deadline
4289    /// that drops this future leaves the walk as it was before that step.
4290    /// With a per-graph budget the walk stops at a counted limb of it (its
4291    /// calls; the bytes it was served or the nodes it appended, bsv-low #586)
4292    /// or at its `deadline`, and answers `Deferred` with the limb's name;
4293    /// without one it runs to its end. A limb is read BEFORE a step, so a
4294    /// pass always makes one: no node is too big to walk.
4295    async fn walk_graph(
4296        &self,
4297        mut deadline: Option<&mut crate::engine::SleepFuture>,
4298    ) -> Result<WalkEnd, GASPError> {
4299        loop {
4300            let (item, over) = {
4301                let walk = self.walk.borrow();
4302                let Some(walk) = walk.as_ref() else {
4303                    return Ok(WalkEnd::Done);
4304                };
4305                (
4306                    walk.record.pending.last().cloned(),
4307                    self.graph_budget.as_ref().and_then(|_| walk.over()),
4308                )
4309            };
4310            let Some(item) = item else {
4311                return Ok(WalkEnd::Done);
4312            };
4313            // One failed round trip per decoy: an outpoint pruned earlier in
4314            // this sync is not asked for again, even when a later graph of
4315            // the sync names it (two UTXOs of one decoy-bearing transaction).
4316            if item.parent_proven && self.pruned.borrow().contains(&item.outpoint) {
4317                debug!(
4318                    "{} Input {} of {:?} was already pruned in this sync; not re-requested",
4319                    self.log_prefix, item.outpoint, item.spent_by
4320                );
4321                if let Some(w) = self.walk.borrow_mut().as_mut() {
4322                    w.record.pending.pop();
4323                }
4324                continue;
4325            }
4326            if let Some(limb) = over {
4327                return Ok(WalkEnd::Deferred(limb));
4328            }
4329            let step = self.step(&item);
4330            let out = match deadline.as_mut() {
4331                Some(deadline) => match race_or_deadline(step, deadline.as_mut()).await {
4332                    Some(out) => out,
4333                    None => return Ok(self.root_bound("time")),
4334                },
4335                None => step.await,
4336            };
4337            if crate::meter::root_exhausted() || matches!(out, Err(GASPError::CourierBudget)) {
4338                return Ok(self.root_bound("courier_rungs"));
4339            }
4340            let out = out?;
4341            if matches!(out, StepOut::Expired) {
4342                return Ok(WalkEnd::Expired);
4343            }
4344            self.commit(out);
4345        }
4346    }
4347
4348    /// Commit one finished step to the walk in hand: its item is no longer
4349    /// pending, and an appended node's needed inputs are, the first of them
4350    /// on top.
4351    fn commit(&self, out: StepOut) {
4352        let mut walk = self.walk.borrow_mut();
4353        let Some(walk) = walk.as_mut() else {
4354            return;
4355        };
4356        walk.record.pending.pop();
4357        if let StepOut::Appended {
4358            node_id,
4359            walked,
4360            children,
4361        } = out
4362        {
4363            walk.seen.insert(node_id);
4364            walk.nodes_this_pass += 1;
4365            self.nodes_appended.set(self.nodes_appended.get() + 1);
4366            walk.record.nodes.push(*walked);
4367            walk.record.pending.extend(children.into_iter().rev());
4368        }
4369    }
4370
4371    /// The walk from a root already in hand (the lib tests' entry, from
4372    /// before the walk was a stack): its proof hydrated, then every needed
4373    /// input to the end.
4374    #[cfg(test)]
4375    async fn process_incoming_node(
4376        &self,
4377        node: &GASPNode,
4378        _spent_by: Option<&str>,
4379        _seen: &mut std::collections::HashSet<String>,
4380    ) -> Result<(), GASPError> {
4381        let root = PendingInput {
4382            outpoint: node.graph_id.clone(),
4383            graph_id: node.graph_id.clone(),
4384            metadata: true,
4385            spent_by: None,
4386            parent_proven: false,
4387        };
4388        *self.walk.borrow_mut() = Some(Walk {
4389            record: DeferredGraph {
4390                peer: String::new(),
4391                topic: String::new(),
4392                outpoint: node.graph_id.clone(),
4393                score: 0,
4394                nodes: Vec::new(),
4395                pending: vec![root.clone()],
4396                calls: 0,
4397                passes: 0,
4398                reason: String::new(),
4399                configured: false,
4400                idle_faults: 0,
4401                cursor_passable: false,
4402                faults: 0,
4403                retry_at: 0,
4404                idle_resumes: 0,
4405                unanchorable_retries: 0,
4406                expiry: None,
4407            },
4408            seen: std::collections::HashSet::new(),
4409            calls_this_pass: 0,
4410            bytes_this_pass: 0,
4411            nodes_this_pass: 0,
4412            resumed: false,
4413            cap: PassCap::NONE,
4414            nodes_at_start: 0,
4415            counted: false,
4416            gone_on: false,
4417            half: None,
4418            struck: None,
4419        });
4420        let node = self.hydrate_root(node.clone()).await;
4421        let out = self.absorb(&root, node).await?;
4422        self.commit(out);
4423        self.walk_graph(None).await.map(|_| ())
4424    }
4425
4426    /// The ROOT of a graph: the peer's node for the UTXO, its own proof
4427    /// hydrated by the ancestor fetcher when it has none.
4428    async fn fetch_root(&self, item: &PendingInput) -> Result<Option<GASPNode>, GASPError> {
4429        let (txid, oi) = parse_outpoint(&item.outpoint)
4430            .ok_or_else(|| GASPError::Other(format!("bad outpoint {}", item.outpoint)))?;
4431        self.count_call();
4432        // Boxed: the batch's answer stays off the walk's future (clippy's
4433        // `large_futures` on the sync that holds it).
4434        let node = Box::pin(self.root_node(item, &txid, oi)).await?;
4435        self.count_fetched(&node);
4436        if self.root_expired(&node, &item.outpoint)? {
4437            return Ok(None);
4438        }
4439        Ok(Some(self.hydrate_root(node).await))
4440    }
4441
4442    /// The peer's node for a root: its batch answer, or the single route's.
4443    async fn root_node(
4444        &self,
4445        item: &PendingInput,
4446        txid: &str,
4447        output_index: u32,
4448    ) -> Result<GASPNode, GASPError> {
4449        if let Some(answer) = self.prefetched_root(item).await {
4450            return answer;
4451        }
4452        self.remote
4453            .request_node(&item.graph_id, txid, output_index, item.metadata)
4454            .await
4455    }
4456
4457    /// Queue the page's fresh roots for the batch request (engine 0.2.1):
4458    /// the outputs `fresh` keeps, less those remembered expired and those
4459    /// held in a record (resumed by their own path). A peer asked one node at
4460    /// a time queues nothing.
4461    fn queue_batch(&self, page: impl Iterator<Item = String>, fresh: impl Fn(&str) -> bool) {
4462        self.batch.prefetched.borrow_mut().clear();
4463        let mut queue = self.batch.queue.borrow_mut();
4464        queue.clear();
4465        if self.batch.single_only.get() || !self.remote.batches() {
4466            return;
4467        }
4468        let expired = self.expired.borrow();
4469        let deferred = self.deferred.borrow();
4470        queue.extend(page.filter(|o| fresh(o) && !expired.contains(o) && !deferred.contains(o)));
4471    }
4472
4473    /// Ask the peer once, for the rest of the run, one node at a time.
4474    fn batch_refused(&self, why: &str) {
4475        warn!(
4476            "{} The node batch is refused ({}); the peer is asked one node at a time for the rest of the run (engine 0.2.1)",
4477            self.log_prefix, why
4478        );
4479        self.batch.single_only.set(true);
4480        self.batch.queue.borrow_mut().clear();
4481    }
4482
4483    /// The root's answer from a batch body (engine 0.2.1, #58), or `None`
4484    /// when it is asked on the single route: the peer does not batch, the
4485    /// root was not queued, or the batch did not answer it.
4486    ///
4487    /// A missing answer asks the batch for the queued roots from this one
4488    /// on, up to [`crate::node_batch::NODE_BATCH_MAX_OUTPOINTS`]. Each entry
4489    /// becomes what its single request would have answered: a node is the
4490    /// node, `not_found` is `GASPError::NodeNotFound` (the peer's word, as a
4491    /// single HTTP 400), so the walk, the absence rule, the fault memo and
4492    /// the proof window read it unchanged. Any other entry, and a root the
4493    /// budget left out, is asked again: the left out in the next body, the
4494    /// refused on the single route. A failed batch, or an answer over
4495    /// [`crate::node_batch::NODE_BATCH_MAX_BYTES`], counts nothing against
4496    /// the peer: the run asks it one node at a time from here on.
4497    async fn prefetched_root(&self, item: &PendingInput) -> Option<Result<GASPNode, GASPError>> {
4498        use crate::node_batch::{
4499            refusal, BatchOutpoint, NODE_BATCH_MAX_BYTES, NODE_BATCH_MAX_OUTPOINTS,
4500        };
4501        if item.graph_id != item.outpoint {
4502            return None;
4503        }
4504        if let Some(answer) = self.batch.prefetched.borrow_mut().remove(&item.outpoint) {
4505            return Some(answer);
4506        }
4507        if self.batch.single_only.get() {
4508            return None;
4509        }
4510        let asked: Vec<String> = {
4511            let mut queue = self.batch.queue.borrow_mut();
4512            let at = queue.iter().position(|o| *o == item.outpoint)?;
4513            queue.drain(..at);
4514            let n = queue.len().min(NODE_BATCH_MAX_OUTPOINTS);
4515            queue.drain(..n).collect()
4516        };
4517        let request: Vec<BatchOutpoint> = asked
4518            .iter()
4519            .filter_map(|o| parse_outpoint(o))
4520            .map(|(txid, vout)| BatchOutpoint { txid, vout })
4521            .collect();
4522        let batch = match self.remote.request_nodes(&request).await {
4523            Ok(batch) => batch,
4524            Err(e) => {
4525                self.batch_refused(&e.to_string());
4526                return None;
4527            }
4528        };
4529        if batch.bytes > NODE_BATCH_MAX_BYTES {
4530            self.batch_refused(&format!(
4531                "an answer of {} bytes is over its budget of {NODE_BATCH_MAX_BYTES}",
4532                batch.bytes
4533            ));
4534            return None;
4535        }
4536        let mut answered = std::collections::HashSet::new();
4537        let mut served = 0u64;
4538        {
4539            let mut prefetched = self.batch.prefetched.borrow_mut();
4540            for entry in batch.answer.nodes {
4541                let key = entry.key();
4542                if !asked.contains(&key) || !answered.insert(key.clone()) {
4543                    continue;
4544                }
4545                match (entry.node, entry.refused.as_deref()) {
4546                    (Some(node), None) => {
4547                        served += 1;
4548                        prefetched.insert(key, Ok(node));
4549                    }
4550                    (None, Some(refusal::NOT_FOUND)) => {
4551                        prefetched.insert(
4552                            key.clone(),
4553                            Err(GASPError::NodeNotFound(format!(
4554                                "{key}: the peer answered not_found in a node batch"
4555                            ))),
4556                        );
4557                    }
4558                    // Asked on the single route, whose answer stands.
4559                    _ => {}
4560                }
4561            }
4562        }
4563        crate::meter::batch_nodes(served);
4564        // Engine 0.2.2: the store is asked once for what these nodes'
4565        // admissions will read (their held inputs).
4566        let nodes: Vec<GASPNode> = {
4567            let prefetched = self.batch.prefetched.borrow();
4568            asked
4569                .iter()
4570                .filter_map(|o| match prefetched.get(o) {
4571                    Some(Ok(node)) => Some(node.clone()),
4572                    _ => None,
4573                })
4574                .collect()
4575        };
4576        if !nodes.is_empty() {
4577            let nodes: Vec<&GASPNode> = nodes.iter().collect();
4578            self.storage.prefetch_page(&[], &nodes).await;
4579        }
4580        let mut queue = self.batch.queue.borrow_mut();
4581        for left_out in asked
4582            .iter()
4583            .rev()
4584            .filter(|o| !answered.contains(*o) && **o != item.outpoint)
4585        {
4586            queue.push_front(left_out.clone());
4587        }
4588        drop(queue);
4589        self.batch.prefetched.borrow_mut().remove(&item.outpoint)
4590    }
4591
4592    /// The root's own proof, hydrated by the ancestor fetcher when the peer
4593    /// served it with none.
4594    async fn hydrate_root(&self, mut node: GASPNode) -> GASPNode {
4595        // The peer already supplied the raw. Ask our proof source for only
4596        // its verified BUMP, inside the graph and peer deadlines. Production
4597        // re-verifies courier proofs against CHAINTRACKS; the graph's anchor
4598        // check still runs before any admission. A resumed proofless root
4599        // takes this path too, so a kept graph can land once its proof exists.
4600        if node.proof.is_none() {
4601            if let Some(fetcher) = &self.ancestor_fetcher {
4602                if let Some(node_txid) =
4603                    Self::node_id(&node).and_then(|id| parse_outpoint(&id).map(|(txid, _)| txid))
4604                {
4605                    self.count_call();
4606                    node.proof = fetcher.verified_proof_for(&node_txid).await;
4607                }
4608            }
4609        }
4610        self.root_proofs
4611            .borrow_mut()
4612            .insert(node.graph_id.clone(), node.proof.is_some());
4613        node
4614    }
4615
4616    /// One step of the walk: fetch `item`, append it to the graph, name its
4617    /// needed inputs. Nothing of the walk is changed here (see
4618    /// [`Self::walk_graph`]).
4619    async fn step(&self, item: &PendingInput) -> Result<StepOut, GASPError> {
4620        // THE D8 DECOY RULE (zanaadu-v2 #314, the owner's ruling of
4621        // 2026-10-06). A DELIBERATE DIVERGENCE from the reference.
4622        //
4623        // The reference requests every needed input with no catch
4624        // (`GASP.ts:602`, `await this.remote.requestNode(...)` inside
4625        // `processIncomingNode`), so one input the peer cannot serve
4626        // throws out of the walk and the per-UTXO catch in `sync`
4627        // drops that whole UTXO: the graph is never completed.
4628        //
4629        // We keep that for an UNPROVEN parent and diverge for a PROVEN
4630        // one. A proven parent needs no input for SPV; the only
4631        // inputs `find_needed_inputs` returns for it are the ones its
4632        // topic manager NAMED as history. A manager cannot always
4633        // tell which input is the real one: a head covenant that
4634        // signs under ANYONECANPAY lets a spender place a decoy
4635        // witness-shaped input ahead of the real head input, and once
4636        // mined it is permanent. The pairing is that such a manager
4637        // names EVERY witness-shaped input, and the engine PRUNES the
4638        // branch of a named input the PEER DEFINITELY DOES NOT HOLD
4639        // instead of discarding the graph: warn, count, carry on with
4640        // the parent's other named inputs, complete the graph with
4641        // what it has. Under the reference's rule one decoy would
4642        // strand the chain behind it on every sync, forever.
4643        //
4644        // An unproven parent's inputs are SPV necessities, not named
4645        // history: a missing one still fails the UTXO (the `?` below),
4646        // exactly as the reference does.
4647        //
4648        // THE CLASSIFICATION RULE. A named input is pruned only on a
4649        // DEFINITE answer: the peer said it does not hold the
4650        // outpoint, the typed class `GASPError::NodeNotFound`. Every
4651        // other error (the request could not be sent, a timeout, a
4652        // 5xx, a 429, a body that does not parse) is a fault of the
4653        // moment and says nothing about what the peer holds: it
4654        // fails the UTXO as the reference does, the per-UTXO arm of
4655        // `sync` records it in the cursor gap guard, and the next
4656        // sync asks again. Pruning on such a fault would cut the
4657        // REAL head input, finalize a truncated graph, advance the
4658        // cursor and never ask for that history again. The remote
4659        // decides the class: the worker's maps HTTP 400 from
4660        // `/requestForeignGASPNode` to `NodeNotFound` and nothing
4661        // else (`gasp_remote.rs`), because 400 is what the reference
4662        // peer answers for an outpoint it does not hold. Accepted
4663        // residual: the reference answers the same masked 400 when
4664        // its own storage faults inside `provideForeignGASPNode`, so
4665        // that fault at a reference peer reads as "not held".
4666        //
4667        // THE FETCHER ARM NEVER PRUNES. A chain fetcher has no
4668        // definite "cannot serve": every input of a mined transaction
4669        // exists on chain, so each of its errors is a fault of the
4670        // moment (its per-tick budget, a provider outage, a rate
4671        // limit), whatever class it carries. With a real chain
4672        // fetcher a decoy is SERVED from chain (one fetch, no failed
4673        // round trip, no prune), so the prune lives on the peer arm,
4674        // which is what runs when no fetcher is installed.
4675        //
4676        // NOTHING HERE BOUNDS THE DECOYS OF ONE PARENT. The manager's
4677        // list has no cap, and each definite decoy is one sequential
4678        // failed round trip inside the per-peer sync budget. A parent
4679        // with enough of them exceeds that budget and the sync is
4680        // dropped whole; what a dropped sync keeps is bsv-low #552's
4681        // ground, and a cap on the list is the manager's choice.
4682        // Each failure below is marked with its fault class (engine 0.1.9),
4683        // read where the root is dropped.
4684        let node = if item.spent_by.is_none() {
4685            let Some(node) = self
4686                .fetch_root(item)
4687                .await
4688                .map_err(|e| self.faulted(Self::peer_fault_class(&e), e))?
4689            else {
4690                return Ok(StepOut::Expired);
4691            };
4692            node
4693        } else {
4694            let Some((txid, oi)) = parse_outpoint(&item.outpoint) else {
4695                return Ok(StepOut::Pruned);
4696            };
4697            match &self.ancestor_fetcher {
4698                // OPT-IN ancestry hydration (off by default). When a
4699                // fetcher is configured we KNOW the peer cannot serve
4700                // ancestry — e.g. legacy beta stores minimal BEEFs and
4701                // returns HTTP 400 "Incomplete SPV data!" for every
4702                // ancestor — so we SKIP the doomed peer round-trip
4703                // entirely and fetch the ancestor's raw tx from chain
4704                // directly. Asking the peer first would cost one
4705                // sequential request per ancestor (the ~90-deep
4706                // user_registry chain → ~90 round-trips), enough to
4707                // exhaust a worker invocation before the graph finalizes.
4708                //
4709                // The fetcher returns the ancestor's rawtx and, when it
4710                // is mined, its BUMP proof. A proven node ends the walk
4711                // UNLESS its topic manager names inputs. Empty managers
4712                // still stop at the first proven layer; managers needing
4713                // history select their ancestors through this same
4714                // fetcher. Without a proof, every input is requested
4715                // as before.
4716                // The transaction DAG (no cycles), `seen` keyed by
4717                // `txid.outputIndex`, the already-known strip, and the
4718                // manager naming nothing at genesis bound the walk.
4719                // There is no node cap, matching the reference's
4720                // undefined `maxNodesInGraph` in `Engine.startGASPSync`.
4721                // A per-peer budget drops the sync future at its
4722                // deadline: graphs finalized before it stay admitted
4723                // (bsv-low #552), the graph in flight is lost whole,
4724                // and ONE graph whose own walk outlasts the budget
4725                // never completes unless a per-graph budget defers and
4726                // resumes it (bsv-low #555).
4727                //
4728                // Every fetcher error fails the UTXO: the fetcher arm
4729                // never prunes, see the rule above.
4730                //
4731                // THE PEER DECIDES ABSENCE (engine 0.1.15, #56). An input
4732                // of an UNPROVEN node the fetcher cannot serve is asked of
4733                // the peer, one request: a rung fetches, it never decides.
4734                // On a deployment with no rung configured (the stack's
4735                // production since 2026-10-14) the fetcher answers every
4736                // such input "exhausted" with no request, so before 0.1.15
4737                // the peer was never asked and a graph the chain never
4738                // held was named `courier`, never the absence it is. The
4739                // peer serving it, the walk goes on; its `NodeNotFound` is
4740                // its word that nothing of this graph can anchor
4741                // ([`fault_class::NOT_FOUND`]); any other answer keeps the
4742                // fetcher's error and class. A proven node's named inputs
4743                // and a spent courier budget are as before.
4744                Some(fetcher) => {
4745                    self.count_call();
4746                    let ancestor = match fetcher.fetch_ancestor(&txid).await {
4747                        Ok(ancestor) => ancestor,
4748                        Err(e) => {
4749                            let class = if matches!(e, GASPError::DefinitiveNotFound(_)) {
4750                                fault_class::NOT_FOUND
4751                            } else {
4752                                fault_class::COURIER
4753                            };
4754                            if item.parent_proven
4755                                || matches!(e, GASPError::CourierBudget)
4756                                || crate::meter::root_exhausted()
4757                            {
4758                                return Err(self.faulted(class, e));
4759                            }
4760                            return match self.ask_peer_for_input(item, &txid, oi).await {
4761                                Ok(node) => self
4762                                    .absorb(item, node)
4763                                    .await
4764                                    .map_err(|e| self.faulted(fault_class::VERIFICATION, e)),
4765                                Err(peer @ GASPError::NodeNotFound(_)) => {
4766                                    Err(self.faulted(fault_class::NOT_FOUND, peer))
4767                                }
4768                                Err(_) => Err(self.faulted(class, e)),
4769                            };
4770                        }
4771                    };
4772                    let node = GASPNode {
4773                        graph_id: item.graph_id.clone(),
4774                        raw_tx: ancestor.raw_tx,
4775                        output_index: oi,
4776                        proof: ancestor.proof,
4777                        tx_metadata: None,
4778                        output_metadata: None,
4779                        inputs: None,
4780                    };
4781                    self.count_fetched(&node);
4782                    node
4783                }
4784                // No fetcher, an unproven parent: ask the peer; its
4785                // `NodeNotFound` is absence (engine 0.1.15), remembered for
4786                // the rest of the sync.
4787                None if !item.parent_proven => self
4788                    .ask_peer_for_input(item, &txid, oi)
4789                    .await
4790                    .map_err(|e| self.faulted(Self::peer_fault_class(&e), e))?,
4791                // No fetcher, a proven parent → ask the peer.
4792                None => {
4793                    self.count_call();
4794                    match self
4795                        .remote
4796                        .request_node(&item.graph_id, &txid, oi, item.metadata)
4797                        .await
4798                    {
4799                        Ok(child_node) => {
4800                            self.count_fetched(&child_node);
4801                            child_node
4802                        }
4803                        // Proven parent and a DEFINITE "not held": the D8 prune
4804                        // (see the rule above).
4805                        Err(e @ GASPError::NodeNotFound(_)) if item.parent_proven => {
4806                            warn!(
4807                            "{} Pruned input {} named by proven node {}: the peer does not hold it: {}",
4808                            self.log_prefix,
4809                            item.outpoint,
4810                            item.spent_by.as_deref().unwrap_or_default(),
4811                            e
4812                        );
4813                            if self.pruned.borrow_mut().insert(item.outpoint.clone()) {
4814                                self.pruned_inputs.set(self.pruned_inputs.get() + 1);
4815                            }
4816                            return Ok(StepOut::Pruned);
4817                        }
4818                        // Any other error: propagate, as the reference does.
4819                        Err(e) => return Err(self.faulted(Self::peer_fault_class(&e), e)),
4820                    }
4821                }
4822            }
4823        };
4824        self.absorb(item, node)
4825            .await
4826            .map_err(|e| self.faulted(fault_class::VERIFICATION, e))
4827    }
4828
4829    /// Ask the peer for `item`, an input an UNPROVEN node needs (engine
4830    /// 0.1.15): one request, or none when the peer already answered in this
4831    /// sync that it does not hold it (counted a peer-absence memo hit).
4832    async fn ask_peer_for_input(
4833        &self,
4834        item: &PendingInput,
4835        txid: &str,
4836        output_index: u32,
4837    ) -> Result<GASPNode, GASPError> {
4838        if self.absent.borrow().contains(&item.outpoint) {
4839            crate::meter::peer_absent_memo_hit();
4840            return Err(GASPError::NodeNotFound(format!(
4841                "{}: the peer answered it does not hold it earlier in this sync",
4842                item.outpoint
4843            )));
4844        }
4845        self.count_call();
4846        match self
4847            .remote
4848            .request_node(&item.graph_id, txid, output_index, item.metadata)
4849            .await
4850        {
4851            Ok(node) => {
4852                self.count_fetched(&node);
4853                Ok(node)
4854            }
4855            Err(e) => {
4856                if matches!(e, GASPError::NodeNotFound(_)) {
4857                    self.absent.borrow_mut().insert(item.outpoint.clone());
4858                }
4859                Err(e)
4860            }
4861        }
4862    }
4863
4864    /// The node of `item`, fetched: append it to the graph and name its
4865    /// needed inputs.
4866    async fn absorb(&self, item: &PendingInput, node: GASPNode) -> Result<StepOut, GASPError> {
4867        // Key by the node's own TXID (computed from raw_tx), NOT graph_id (which
4868        // is constant across a whole graph) and NOT the raw_tx hex. Mirrors TS
4869        // @bsv/gasp processIncomingNode: nodeId = `${computeTXID(rawTx)}.${oi}`
4870        // (GASP.js:319) and spentBy = compute36ByteStructure(computeTXID(rawTx), oi)
4871        // (GASP.js:335). Matches the append-side key in gasp_overlay.rs
4872        // (Transaction::from_hex(raw_tx).id()). Using graph_id/raw_tx here orphaned
4873        // every child node → multi-node graphs never assembled → stranded sync.
4874        let node_txid = match bsv_rs::transaction::Transaction::from_hex(&node.raw_tx) {
4875            Ok(tx) => tx.id(),
4876            Err(_) => node.raw_tx[..node.raw_tx.len().min(64)].to_string(),
4877        };
4878        let node_id = format!("{}.{}", node_txid, node.output_index);
4879        if self
4880            .walk
4881            .borrow()
4882            .as_ref()
4883            .is_some_and(|w| w.seen.contains(&node_id))
4884        {
4885            return Ok(StepOut::Seen);
4886        }
4887
4888        self.storage
4889            .append_to_graph(&node, item.spent_by.as_deref())
4890            .await?;
4891
4892        let mut children = Vec::new();
4893        if let Some(needed) = self.storage.find_needed_inputs(&node).await? {
4894            let parent_proven = node.proof.is_some();
4895            for (outpoint, input_req) in &needed.requested_inputs {
4896                if parse_outpoint(outpoint).is_some() {
4897                    children.push(PendingInput {
4898                        outpoint: outpoint.clone(),
4899                        graph_id: node.graph_id.clone(),
4900                        metadata: input_req.metadata,
4901                        spent_by: Some(node_id.clone()),
4902                        parent_proven,
4903                    });
4904                }
4905            }
4906        }
4907        Ok(StepOut::Appended {
4908            node_id,
4909            walked: Box::new(WalkedNode {
4910                node,
4911                spent_by: item.spent_by.clone(),
4912            }),
4913            children,
4914        })
4915    }
4916
4917    /// Process an outgoing node: submit to remote, then recursively send requested inputs.
4918    fn process_outgoing_node<'b>(
4919        &'b self,
4920        node: &'b GASPNode,
4921        seen: &'b mut std::collections::HashSet<String>,
4922    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), GASPError>> + 'b>> {
4923        Box::pin(async move {
4924            if self.unidirectional {
4925                return Ok(());
4926            }
4927
4928            let node_id = format!("{}.{}", node.graph_id, node.output_index);
4929            if seen.contains(&node_id) {
4930                return Ok(());
4931            }
4932            seen.insert(node_id);
4933
4934            if let Some(response) = self.remote.submit_node(node).await? {
4935                for (outpoint, input_req) in &response.requested_inputs {
4936                    if let Some((txid, oi)) = parse_outpoint(outpoint) {
4937                        match self
4938                            .storage
4939                            .hydrate_gasp_node(&node.graph_id, &txid, oi, input_req.metadata)
4940                            .await
4941                        {
4942                            Ok(hydrated) => {
4943                                self.process_outgoing_node(&hydrated, seen).await?;
4944                            }
4945                            Err(e) => {
4946                                error!("{} Error hydrating outgoing node: {}", self.log_prefix, e);
4947                                return Ok(()); // Stop this branch, remote will discard
4948                            }
4949                        }
4950                    }
4951                }
4952            }
4953
4954            Ok(())
4955        })
4956    }
4957
4958    /// Validate and finalize a completed graph, or discard on failure.
4959    /// `Ok(true)`: finalized; `Ok(false)`: refused by the anchor check and
4960    /// discarded (a verdict: the cursor moves past it).
4961    async fn complete_graph(&self, graph_id: &str) -> Result<bool, GASPError> {
4962        info!("{} Completing graph: {}", self.log_prefix, graph_id);
4963        match self.storage.validate_graph_anchor(graph_id).await {
4964            Ok(()) => {
4965                self.storage.finalize_graph(graph_id).await?;
4966                let proven =
4967                    self.root_proofs
4968                        .borrow_mut()
4969                        .remove(graph_id)
4970                        .or_else(|| {
4971                            self.walk.borrow().as_ref().and_then(|w| {
4972                                match w.record.nodes.as_slice() {
4973                                    [root, ..] => Some(root.node.proof.is_some()),
4974                                    [] => None,
4975                                }
4976                            })
4977                        })
4978                        .unwrap_or(false);
4979                let count = if proven {
4980                    &self.roots_proven
4981                } else {
4982                    &self.roots_unproven
4983                };
4984                count.set(count.get() + 1);
4985                info!("{} Graph finalized: {}", self.log_prefix, graph_id);
4986                Ok(true)
4987            }
4988            Err(e) => {
4989                warn!(
4990                    "{} Graph validation failed for {}: {}. Discarding.",
4991                    self.log_prefix, graph_id, e
4992                );
4993                self.storage.discard_graph(graph_id).await?;
4994                // The anchor could not be checked (a tracker outage, a
4995                // storage fault, a topic manager that answered the replay
4996                // with an error): no verdict. Fail the UTXO so the gap guard
4997                // re-requests it, instead of advancing the cursor past a
4998                // graph nobody judged (a divergence by addition, bsv-low
4999                // #551: the reference discards and moves on either way).
5000                if matches!(e, GASPError::AnchorUnavailable(_)) {
5001                    return Err(e);
5002                }
5003                // A walk past the budget: held the same way, counted apart
5004                // (the E592 lens fold, H1 and L3).
5005                if matches!(e, GASPError::AnchorWalkHeld(_)) {
5006                    self.anchor_walk_held.set(self.anchor_walk_held.get() + 1);
5007                    return Err(e);
5008                }
5009                self.discarded_graphs.set(self.discarded_graphs.get() + 1);
5010                Ok(false)
5011            }
5012        }
5013    }
5014}
5015
5016/// Parse "txid.outputIndex" into components.
5017pub fn parse_outpoint(s: &str) -> Option<(String, u32)> {
5018    let parts: Vec<&str> = s.splitn(2, '.').collect();
5019    if parts.len() != 2 {
5020        return None;
5021    }
5022    let oi = parts[1].parse::<u32>().ok()?;
5023    Some((parts[0].to_string(), oi))
5024}
5025
5026// ============================================================================
5027// Error type
5028// ============================================================================
5029
5030/// GASP protocol errors.
5031#[derive(Debug, Clone, thiserror::Error)]
5032pub enum GASPError {
5033    /// Every enabled raw rung answered absence, with no skipped or failed rung.
5034    /// Since engine 0.1.15 it counts one run of absence beside the peer's
5035    /// word ([`fault_class::NOT_FOUND`]), never a strike by itself.
5036    #[error("all raw couriers report not found: {0}")]
5037    DefinitiveNotFound(String),
5038    /// The pass spent its courier allowance; retain its pending walk.
5039    #[error("root courier rung budget exhausted")]
5040    CourierBudget,
5041    /// Version mismatch between peers.
5042    #[error("version mismatch: local={local}, remote={remote}")]
5043    VersionMismatch { local: u32, remote: u32 },
5044
5045    /// Invalid timestamp format.
5046    #[error("invalid timestamp: {0}")]
5047    InvalidTimestamp(String),
5048
5049    /// Node not found.
5050    #[error("node not found: {0}")]
5051    NodeNotFound(String),
5052
5053    /// Graph validation failed.
5054    #[error("graph validation failed: {0}")]
5055    ValidationFailed(String),
5056
5057    /// Network/communication error.
5058    #[error("remote error: {0}")]
5059    RemoteError(String),
5060
5061    /// The platform answered before the peer was reached (engine 0.2.0,
5062    /// bsv-overlay-cloudflare #57; [`crate::route_fault`]): a fault of the
5063    /// route, never of the peer. It counts nothing against the peer's
5064    /// health rows and is named `route_fault:<class>`.
5065    #[error("route_fault:{class}: {detail}")]
5066    RouteFault { class: String, detail: String },
5067
5068    /// Storage error.
5069    #[error("storage error: {0}")]
5070    StorageError(String),
5071
5072    /// A graph's anchor could not be CHECKED right now (the chain tracker or
5073    /// a storage read faulted, or the topic manager answered the replay with
5074    /// an error): a fault of the moment, not a verdict on the graph. `GASPSync::complete_graph` discards the graph and fails the
5075    /// UTXO, so the cursor gap guard asks for it again (bsv-low #551).
5076    #[error("anchor unavailable: {0}")]
5077    AnchorUnavailable(String),
5078
5079    /// The anchor's script walk could not run within the engine's walk
5080    /// budget (bsv-low #592; the E592 lens fold, H1): no verdict, and
5081    /// nothing stands in for one (no network accepted these bytes). Held
5082    /// exactly as [`GASPError::AnchorUnavailable`] holds: the graph is not
5083    /// finalized, the UTXO fails and the cursor stays below it, until the
5084    /// peer serves its root PROVEN (#555's `root_proven` restart, whose walk
5085    /// trusts the proof and runs no script). Counted apart
5086    /// (`GASPSync::anchor_walk_held`).
5087    #[error("anchor walk held: {0}")]
5088    AnchorWalkHeld(String),
5089
5090    /// Generic error.
5091    #[error("{0}")]
5092    Other(String),
5093}
5094
5095// ============================================================================
5096// Tests
5097// ============================================================================
5098
5099#[cfg(test)]
5100mod tests {
5101    use super::*;
5102    use std::collections::HashMap;
5103    use std::sync::Mutex;
5104
5105    // ── race_or_deadline (bsv-low#257/#302, generalized here) ──────────
5106
5107    #[tokio::test]
5108    async fn race_or_deadline_future_wins() {
5109        let out = race_or_deadline(async { 42u32 }, std::future::pending::<()>()).await;
5110        assert_eq!(out, Some(42));
5111    }
5112
5113    #[tokio::test]
5114    async fn race_or_deadline_deadline_wins() {
5115        let out = race_or_deadline(std::future::pending::<u32>(), async {}).await;
5116        assert_eq!(out, None);
5117    }
5118
5119    // The lens fold's HIGH-1: a deadline that falls due inside a write
5120    // section waits for the section to close, then drops the future at its
5121    // next pending point; the writer that asks at the boundary stops there.
5122    #[tokio::test]
5123    async fn guarded_race_never_drops_inside_a_write_section() {
5124        let gate = SubmitGate::default();
5125        let written = std::cell::Cell::new(0u32);
5126        let out = race_or_deadline_guarded(
5127            async {
5128                for _ in 0..3 {
5129                    gate.stop_if_due().await;
5130                    let _section = gate.write_section();
5131                    // Two writes of one transaction, each a round trip.
5132                    tokio::task::yield_now().await;
5133                    written.set(written.get() + 1);
5134                    tokio::task::yield_now().await;
5135                    written.set(written.get() + 1);
5136                }
5137            },
5138            async {},
5139            &gate,
5140        )
5141        .await;
5142        assert_eq!(out, None, "the deadline still wins");
5143        assert_eq!(written.get(), 2, "one transaction, whole, and no second");
5144        assert!(
5145            !gate.deadline_is_due(),
5146            "the flag does not outlive the race"
5147        );
5148
5149        // With no section open it is `race_or_deadline`.
5150        let out = race_or_deadline_guarded(std::future::pending::<u8>(), async {}, &gate).await;
5151        assert_eq!(out, None);
5152        let out = race_or_deadline_guarded(async { 7u8 }, std::future::pending(), &gate).await;
5153        assert_eq!(out, Some(7));
5154        // A future that finishes inside its section after the deadline wins.
5155        let out = race_or_deadline_guarded(
5156            async {
5157                let _section = gate.write_section();
5158                tokio::task::yield_now().await;
5159                7u8
5160            },
5161            async {},
5162            &gate,
5163        )
5164        .await;
5165        assert_eq!(out, Some(7));
5166        assert!(!gate.deadline_is_due());
5167    }
5168
5169    #[tokio::test]
5170    async fn race_or_deadline_ready_future_beats_ready_deadline() {
5171        // The sync future is polled FIRST — an instantly-ready result wins
5172        // even against an already-expired deadline (a completed sync is
5173        // never discarded).
5174        let out = race_or_deadline(async { 7u32 }, async {}).await;
5175        assert_eq!(out, Some(7));
5176    }
5177
5178    // ── peer_sync_quarantined (bsv-low#302 pure rule) ──────────────────
5179
5180    fn health(fails: u64, age: Option<u64>) -> crate::storage::PeerSyncHealth {
5181        crate::storage::PeerSyncHealth {
5182            consecutive_failures: fails,
5183            secs_since_last_attempt: age,
5184        }
5185    }
5186
5187    #[test]
5188    fn quarantine_needs_the_full_threshold() {
5189        assert!(!peer_sync_quarantined(&health(0, Some(0))));
5190        assert!(!peer_sync_quarantined(&health(
5191            PEER_QUARANTINE_THRESHOLD - 1,
5192            Some(0)
5193        )));
5194        assert!(peer_sync_quarantined(&health(
5195            PEER_QUARANTINE_THRESHOLD,
5196            Some(0)
5197        )));
5198        assert!(peer_sync_quarantined(&health(
5199            PEER_QUARANTINE_THRESHOLD + 100,
5200            Some(0)
5201        )));
5202    }
5203
5204    #[test]
5205    fn quarantine_reopens_one_probe_after_the_reprobe_window() {
5206        let fails = PEER_QUARANTINE_THRESHOLD;
5207        assert!(peer_sync_quarantined(&health(
5208            fails,
5209            Some(PEER_QUARANTINE_REPROBE_SECS - 1)
5210        )));
5211        // AT the window boundary the probe re-opens — a quarantined peer is
5212        // never stranded forever.
5213        assert!(!peer_sync_quarantined(&health(
5214            fails,
5215            Some(PEER_QUARANTINE_REPROBE_SECS)
5216        )));
5217    }
5218
5219    #[test]
5220    fn never_attempted_peer_is_never_quarantined() {
5221        // `None` age = pristine peer OR a backend without health tracking —
5222        // fail-safe: always attempted.
5223        assert!(!peer_sync_quarantined(&health(u64::MAX, None)));
5224    }
5225
5226    // ── Mock GASPStorage ───────────────────────────────────────────────
5227
5228    struct MockGASPStorage {
5229        utxos: Vec<GASPOutput>,
5230        graphs: Mutex<HashMap<String, Vec<GASPNode>>>,
5231        finalized: Mutex<Vec<String>>,
5232        discarded: Mutex<Vec<String>>,
5233    }
5234
5235    impl MockGASPStorage {
5236        fn new(utxos: Vec<GASPOutput>) -> Self {
5237            Self {
5238                utxos,
5239                graphs: Mutex::new(HashMap::new()),
5240                finalized: Mutex::new(Vec::new()),
5241                discarded: Mutex::new(Vec::new()),
5242            }
5243        }
5244    }
5245
5246    #[async_trait(?Send)]
5247    impl GASPStorage for MockGASPStorage {
5248        // Keeps no deferred graphs (bsv-low #555): never given a per-graph budget.
5249        async fn load_deferred_graphs(
5250            &self,
5251        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
5252            Ok(Vec::new())
5253        }
5254        async fn get_deferred_graph(
5255            &self,
5256            _: &str,
5257        ) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
5258            Ok(None)
5259        }
5260        async fn save_deferred_graph(
5261            &self,
5262            _: &crate::gasp::DeferredGraph,
5263        ) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
5264            Err(GASPError::StorageError("keeps no deferred graphs".into()))
5265        }
5266        async fn delete_deferred_graph(&self, _: &str) -> Result<(), GASPError> {
5267            Ok(())
5268        }
5269        async fn find_known_utxos(
5270            &self,
5271            since: u64,
5272            _limit: Option<u64>,
5273        ) -> Result<Vec<GASPOutput>, GASPError> {
5274            Ok(self
5275                .utxos
5276                .iter()
5277                .filter(|u| u.score as u64 >= since)
5278                .cloned()
5279                .collect())
5280        }
5281
5282        async fn hydrate_gasp_node(
5283            &self,
5284            graph_id: &str,
5285            txid: &str,
5286            output_index: u32,
5287            _metadata: bool,
5288        ) -> Result<GASPNode, GASPError> {
5289            Ok(GASPNode {
5290                graph_id: graph_id.to_string(),
5291                raw_tx: format!("rawtx_{txid}"),
5292                output_index,
5293                proof: None,
5294                tx_metadata: None,
5295                output_metadata: None,
5296                inputs: None,
5297            })
5298        }
5299
5300        async fn find_needed_inputs(
5301            &self,
5302            _node: &GASPNode,
5303        ) -> Result<Option<GASPNodeResponse>, GASPError> {
5304            Ok(None) // No inputs needed for simple tests
5305        }
5306
5307        async fn append_to_graph(
5308            &self,
5309            node: &GASPNode,
5310            _spent_by: Option<&str>,
5311        ) -> Result<(), GASPError> {
5312            self.graphs
5313                .lock()
5314                .unwrap()
5315                .entry(node.graph_id.clone())
5316                .or_default()
5317                .push(node.clone());
5318            Ok(())
5319        }
5320
5321        async fn validate_graph_anchor(&self, _graph_id: &str) -> Result<(), GASPError> {
5322            Ok(())
5323        }
5324
5325        async fn finalize_graph(&self, graph_id: &str) -> Result<(), GASPError> {
5326            self.finalized.lock().unwrap().push(graph_id.to_string());
5327            Ok(())
5328        }
5329
5330        async fn discard_graph(&self, graph_id: &str) -> Result<(), GASPError> {
5331            self.discarded.lock().unwrap().push(graph_id.to_string());
5332            Ok(())
5333        }
5334    }
5335
5336    // ── Mock GASPRemote ────────────────────────────────────────────────
5337
5338    struct MockGASPRemote {
5339        remote_utxos: Vec<GASPOutput>,
5340        submitted: Mutex<Vec<GASPNode>>,
5341    }
5342
5343    impl MockGASPRemote {
5344        fn new(utxos: Vec<GASPOutput>) -> Self {
5345            Self {
5346                remote_utxos: utxos,
5347                submitted: Mutex::new(Vec::new()),
5348            }
5349        }
5350    }
5351
5352    #[async_trait(?Send)]
5353    impl GASPRemote for MockGASPRemote {
5354        async fn get_initial_response(
5355            &self,
5356            request: &GASPInitialRequest,
5357        ) -> Result<GASPInitialResponse, GASPError> {
5358            if request.version != GASP_VERSION {
5359                return Err(GASPError::VersionMismatch {
5360                    local: GASP_VERSION,
5361                    remote: request.version,
5362                });
5363            }
5364            let utxos: Vec<GASPOutput> = self
5365                .remote_utxos
5366                .iter()
5367                .filter(|u| u.score as u64 >= request.since)
5368                .cloned()
5369                .collect();
5370            Ok(GASPInitialResponse {
5371                utxo_list: utxos,
5372                since: request.since,
5373            })
5374        }
5375
5376        async fn get_initial_reply(
5377            &self,
5378            _response: &GASPInitialResponse,
5379        ) -> Result<GASPInitialReply, GASPError> {
5380            Ok(GASPInitialReply {
5381                utxo_list: Vec::new(),
5382            })
5383        }
5384
5385        async fn request_node(
5386            &self,
5387            graph_id: &str,
5388            txid: &str,
5389            output_index: u32,
5390            _metadata: bool,
5391        ) -> Result<GASPNode, GASPError> {
5392            Ok(GASPNode {
5393                graph_id: graph_id.to_string(),
5394                raw_tx: format!("remote_rawtx_{txid}"),
5395                output_index,
5396                proof: Some("proof_hex".to_string()),
5397                tx_metadata: None,
5398                output_metadata: None,
5399                inputs: None,
5400            })
5401        }
5402
5403        async fn submit_node(
5404            &self,
5405            node: &GASPNode,
5406        ) -> Result<Option<GASPNodeResponse>, GASPError> {
5407            self.submitted.lock().unwrap().push(node.clone());
5408            Ok(None) // No further inputs needed
5409        }
5410    }
5411
5412    // ── Tests ──────────────────────────────────────────────────────────
5413
5414    #[tokio::test]
5415    async fn proofs_page_deadline_never_passes_a_failed_unsaved_graph() {
5416        struct PageDeadline(std::rc::Rc<std::cell::Cell<bool>>);
5417        #[async_trait(?Send)]
5418        impl GASPRemote for PageDeadline {
5419            async fn get_initial_response(
5420                &self,
5421                request: &GASPInitialRequest,
5422            ) -> Result<GASPInitialResponse, GASPError> {
5423                if request.since > 0 {
5424                    self.0.set(true);
5425                    std::future::pending::<()>().await;
5426                }
5427                Ok(GASPInitialResponse {
5428                    since: request.since,
5429                    utxo_list: vec![GASPOutput {
5430                        txid: "11".repeat(32),
5431                        output_index: 0,
5432                        score: 10.0,
5433                    }],
5434                })
5435            }
5436            async fn get_initial_reply(
5437                &self,
5438                _: &GASPInitialResponse,
5439            ) -> Result<GASPInitialReply, GASPError> {
5440                unreachable!()
5441            }
5442            async fn request_node(
5443                &self,
5444                _: &str,
5445                _: &str,
5446                _: u32,
5447                _: bool,
5448            ) -> Result<GASPNode, GASPError> {
5449                Err(GASPError::RemoteError("offline".into()))
5450            }
5451            async fn submit_node(
5452                &self,
5453                _: &GASPNode,
5454            ) -> Result<Option<GASPNodeResponse>, GASPError> {
5455                unreachable!()
5456            }
5457        }
5458        let due = std::rc::Rc::new(std::cell::Cell::new(false));
5459        let mut sync = GASPSync::new(
5460            Box::new(MockGASPStorage::new(vec![])),
5461            Box::new(PageDeadline(due.clone())),
5462            0,
5463            "test",
5464            true,
5465        )
5466        .with_graph_budget(GraphBudget {
5467            max_calls: 10,
5468            max_bytes_fetched: u64::MAX,
5469            max_nodes: u32::MAX,
5470            deadline: Box::new(|| Box::pin(std::future::pending())),
5471            half_deadline: None,
5472        });
5473        let deadline = std::future::poll_fn(move |_| {
5474            if due.get() {
5475                std::task::Poll::Ready(())
5476            } else {
5477                std::task::Poll::Pending
5478            }
5479        });
5480        assert!(race_or_deadline(sync.sync(None), deadline).await.is_none());
5481        assert!(!sync.graph_in_flight());
5482        sync.defer_in_flight().await;
5483        assert_eq!(
5484            sync.completed_cursor(),
5485            0,
5486            "a failed graph has no durable hold"
5487        );
5488        assert_eq!(
5489            sync.cursor_held_by(),
5490            Some(format!("{}.0", "11".repeat(32)))
5491        );
5492    }
5493
5494    #[tokio::test]
5495    async fn test_sync_pulls_remote_utxos() {
5496        let local_storage = MockGASPStorage::new(vec![]);
5497        let remote = MockGASPRemote::new(vec![
5498            GASPOutput {
5499                txid: "tx1".to_string(),
5500                output_index: 0,
5501                score: 100.0,
5502            },
5503            GASPOutput {
5504                txid: "tx2".to_string(),
5505                output_index: 0,
5506                score: 200.0,
5507            },
5508        ]);
5509
5510        let mut gasp = GASPSync::new(
5511            Box::new(local_storage),
5512            Box::new(remote),
5513            0,
5514            "[TEST]",
5515            false,
5516        );
5517
5518        gasp.sync(None).await.unwrap();
5519
5520        assert_eq!(gasp.last_interaction, 200);
5521    }
5522
5523    #[tokio::test]
5524    async fn test_sync_skips_known_utxos() {
5525        let local_storage = MockGASPStorage::new(vec![GASPOutput {
5526            txid: "tx1".to_string(),
5527            output_index: 0,
5528            score: 100.0,
5529        }]);
5530        let remote = MockGASPRemote::new(vec![
5531            GASPOutput {
5532                txid: "tx1".to_string(),
5533                output_index: 0,
5534                score: 100.0,
5535            },
5536            GASPOutput {
5537                txid: "tx2".to_string(),
5538                output_index: 0,
5539                score: 200.0,
5540            },
5541        ]);
5542
5543        let mut gasp = GASPSync::new(
5544            Box::new(local_storage),
5545            Box::new(remote),
5546            0,
5547            "[TEST]",
5548            true, // unidirectional — skip push
5549        );
5550
5551        gasp.sync(None).await.unwrap();
5552
5553        // Only tx2 should have been ingested (tx1 was already known)
5554        // last_interaction should be 200
5555        assert_eq!(gasp.last_interaction, 200);
5556    }
5557
5558    #[tokio::test]
5559    async fn test_sync_unidirectional_skips_push() {
5560        let local_storage = MockGASPStorage::new(vec![GASPOutput {
5561            txid: "local_only".to_string(),
5562            output_index: 0,
5563            score: 50.0,
5564        }]);
5565        let remote = MockGASPRemote::new(vec![]);
5566
5567        let mut gasp = GASPSync::new(Box::new(local_storage), Box::new(remote), 0, "[TEST]", true);
5568
5569        gasp.sync(None).await.unwrap();
5570        // In unidirectional mode, local_only should NOT be pushed to remote
5571        // (no way to check directly with current mock, but no error = success)
5572    }
5573
5574    #[tokio::test]
5575    async fn test_complete_graph_finalizes_on_valid() {
5576        let storage = MockGASPStorage::new(vec![]);
5577        let remote = MockGASPRemote::new(vec![]);
5578
5579        let gasp = GASPSync::new(Box::new(storage), Box::new(remote), 0, "[TEST]", false);
5580
5581        gasp.complete_graph("test_graph.0").await.unwrap();
5582        // Storage mock always validates OK, so graph should be finalized
5583    }
5584
5585    #[tokio::test]
5586    async fn test_complete_graph_discards_on_invalid() {
5587        struct FailValidationStorage;
5588
5589        #[async_trait(?Send)]
5590        impl GASPStorage for FailValidationStorage {
5591            // Keeps no deferred graphs (bsv-low #555): never given a per-graph budget.
5592            async fn load_deferred_graphs(
5593                &self,
5594            ) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
5595                Ok(Vec::new())
5596            }
5597            async fn get_deferred_graph(
5598                &self,
5599                _: &str,
5600            ) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
5601                Ok(None)
5602            }
5603            async fn save_deferred_graph(
5604                &self,
5605                _: &crate::gasp::DeferredGraph,
5606            ) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
5607                Err(GASPError::StorageError("keeps no deferred graphs".into()))
5608            }
5609            async fn delete_deferred_graph(&self, _: &str) -> Result<(), GASPError> {
5610                Ok(())
5611            }
5612            async fn find_known_utxos(
5613                &self,
5614                _: u64,
5615                _: Option<u64>,
5616            ) -> Result<Vec<GASPOutput>, GASPError> {
5617                Ok(vec![])
5618            }
5619            async fn hydrate_gasp_node(
5620                &self,
5621                _: &str,
5622                _: &str,
5623                _: u32,
5624                _: bool,
5625            ) -> Result<GASPNode, GASPError> {
5626                unreachable!()
5627            }
5628            async fn find_needed_inputs(
5629                &self,
5630                _: &GASPNode,
5631            ) -> Result<Option<GASPNodeResponse>, GASPError> {
5632                Ok(None)
5633            }
5634            async fn append_to_graph(
5635                &self,
5636                _: &GASPNode,
5637                _: Option<&str>,
5638            ) -> Result<(), GASPError> {
5639                Ok(())
5640            }
5641            async fn validate_graph_anchor(&self, _: &str) -> Result<(), GASPError> {
5642                Err(GASPError::ValidationFailed("bad anchor".into()))
5643            }
5644            async fn finalize_graph(&self, _: &str) -> Result<(), GASPError> {
5645                panic!("should not finalize")
5646            }
5647            async fn discard_graph(&self, _: &str) -> Result<(), GASPError> {
5648                Ok(())
5649            }
5650        }
5651
5652        let gasp = GASPSync::new(
5653            Box::new(FailValidationStorage),
5654            Box::new(MockGASPRemote::new(vec![])),
5655            0,
5656            "[TEST]",
5657            false,
5658        );
5659
5660        // Should not error — discards instead of finalizing
5661        gasp.complete_graph("bad_graph.0").await.unwrap();
5662    }
5663
5664    // ── AncestorFetcher fallback tests ─────────────────────────────────
5665
5666    /// Build a minimal valid tx hex with one input referencing `source_txid`.
5667    fn make_tx_hex(source_txid: &str, source_oi: u32) -> String {
5668        let mut tx = bsv_rs::transaction::Transaction::new();
5669        tx.inputs.push(bsv_rs::transaction::TransactionInput::new(
5670            source_txid.to_string(),
5671            source_oi,
5672        ));
5673        tx.outputs.push(bsv_rs::transaction::TransactionOutput::new(
5674            100,
5675            bsv_rs::script::LockingScript::from_hex(
5676                "76a914000000000000000000000000000000000000000088ac",
5677            )
5678            .unwrap(),
5679        ));
5680        tx.to_hex()
5681    }
5682
5683    /// Storage that records appended nodes and requests exactly one ancestor
5684    /// (the first input) on the FIRST node it sees, then nothing further.
5685    struct RecordingStorage {
5686        appended: Mutex<Vec<GASPNode>>,
5687        request_once: Mutex<bool>,
5688        ancestor_outpoint: String,
5689    }
5690
5691    #[async_trait(?Send)]
5692    impl GASPStorage for RecordingStorage {
5693        // Keeps no deferred graphs (bsv-low #555): never given a per-graph budget.
5694        async fn load_deferred_graphs(
5695            &self,
5696        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
5697            Ok(Vec::new())
5698        }
5699        async fn get_deferred_graph(
5700            &self,
5701            _: &str,
5702        ) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
5703            Ok(None)
5704        }
5705        async fn save_deferred_graph(
5706            &self,
5707            _: &crate::gasp::DeferredGraph,
5708        ) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
5709            Err(GASPError::StorageError("keeps no deferred graphs".into()))
5710        }
5711        async fn delete_deferred_graph(&self, _: &str) -> Result<(), GASPError> {
5712            Ok(())
5713        }
5714        async fn find_known_utxos(
5715            &self,
5716            _: u64,
5717            _: Option<u64>,
5718        ) -> Result<Vec<GASPOutput>, GASPError> {
5719            Ok(vec![])
5720        }
5721        async fn hydrate_gasp_node(
5722            &self,
5723            _: &str,
5724            _: &str,
5725            _: u32,
5726            _: bool,
5727        ) -> Result<GASPNode, GASPError> {
5728            unreachable!()
5729        }
5730        async fn find_needed_inputs(
5731            &self,
5732            _: &GASPNode,
5733        ) -> Result<Option<GASPNodeResponse>, GASPError> {
5734            let mut once = self.request_once.lock().unwrap();
5735            if *once {
5736                *once = false;
5737                let mut requested_inputs = HashMap::new();
5738                requested_inputs.insert(
5739                    self.ancestor_outpoint.clone(),
5740                    crate::types::GASPInputRequest { metadata: false },
5741                );
5742                Ok(Some(GASPNodeResponse { requested_inputs }))
5743            } else {
5744                Ok(None)
5745            }
5746        }
5747        async fn append_to_graph(&self, node: &GASPNode, _: Option<&str>) -> Result<(), GASPError> {
5748            self.appended.lock().unwrap().push(node.clone());
5749            Ok(())
5750        }
5751        async fn validate_graph_anchor(&self, _: &str) -> Result<(), GASPError> {
5752            Ok(())
5753        }
5754        async fn finalize_graph(&self, _: &str) -> Result<(), GASPError> {
5755            Ok(())
5756        }
5757        async fn discard_graph(&self, _: &str) -> Result<(), GASPError> {
5758            Ok(())
5759        }
5760    }
5761
5762    /// Remote that always errors on `request_node` (mimics beta's HTTP 400
5763    /// "Incomplete SPV data!").
5764    struct FailingNodeRemote;
5765
5766    #[async_trait(?Send)]
5767    impl GASPRemote for FailingNodeRemote {
5768        async fn get_initial_response(
5769            &self,
5770            _: &GASPInitialRequest,
5771        ) -> Result<GASPInitialResponse, GASPError> {
5772            Ok(GASPInitialResponse {
5773                utxo_list: vec![],
5774                since: 0,
5775            })
5776        }
5777        async fn get_initial_reply(
5778            &self,
5779            _: &GASPInitialResponse,
5780        ) -> Result<GASPInitialReply, GASPError> {
5781            Ok(GASPInitialReply { utxo_list: vec![] })
5782        }
5783        async fn request_node(
5784            &self,
5785            _: &str,
5786            _: &str,
5787            _: u32,
5788            _: bool,
5789        ) -> Result<GASPNode, GASPError> {
5790            Err(GASPError::RemoteError(
5791                "Peer returned HTTP 400: Incomplete SPV data!".to_string(),
5792            ))
5793        }
5794        async fn submit_node(&self, _: &GASPNode) -> Result<Option<GASPNodeResponse>, GASPError> {
5795            Ok(None)
5796        }
5797    }
5798
5799    /// Mock fetcher that returns a pre-built ancestor rawtx (+ optional proof).
5800    struct MockFetcher {
5801        ancestor_hex: String,
5802        proof: Option<String>,
5803        called: Mutex<u32>,
5804    }
5805
5806    #[async_trait(?Send)]
5807    impl AncestorFetcher for MockFetcher {
5808        async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
5809            *self.called.lock().unwrap() += 1;
5810            Ok(FetchedAncestor {
5811                raw_tx: self.ancestor_hex.clone(),
5812                proof: self.proof.clone(),
5813            })
5814        }
5815    }
5816
5817    /// Remote that records how many times `request_node` is invoked (it would
5818    /// be a doomed peer call on the migration path) so a test can assert the
5819    /// fetcher path SKIPS the peer entirely.
5820    struct CountingNodeRemote {
5821        request_node_calls: std::rc::Rc<Mutex<u32>>,
5822    }
5823
5824    #[async_trait(?Send)]
5825    impl GASPRemote for CountingNodeRemote {
5826        async fn get_initial_response(
5827            &self,
5828            _: &GASPInitialRequest,
5829        ) -> Result<GASPInitialResponse, GASPError> {
5830            Ok(GASPInitialResponse {
5831                utxo_list: vec![],
5832                since: 0,
5833            })
5834        }
5835        async fn get_initial_reply(
5836            &self,
5837            _: &GASPInitialResponse,
5838        ) -> Result<GASPInitialReply, GASPError> {
5839            Ok(GASPInitialReply { utxo_list: vec![] })
5840        }
5841        async fn request_node(
5842            &self,
5843            _: &str,
5844            _: &str,
5845            _: u32,
5846            _: bool,
5847        ) -> Result<GASPNode, GASPError> {
5848            *self.request_node_calls.lock().unwrap() += 1;
5849            Err(GASPError::RemoteError(
5850                "Peer returned HTTP 400: Incomplete SPV data!".to_string(),
5851            ))
5852        }
5853        async fn submit_node(&self, _: &GASPNode) -> Result<Option<GASPNodeResponse>, GASPError> {
5854            Ok(None)
5855        }
5856    }
5857
5858    #[tokio::test]
5859    async fn ancestor_fetcher_none_preserves_peer_error() {
5860        // Build a tip tx whose input references some ancestor.
5861        let ancestor_hex = make_tx_hex(
5862            "1111111111111111111111111111111111111111111111111111111111111111",
5863            0,
5864        );
5865        let ancestor_txid = bsv_rs::transaction::Transaction::from_hex(&ancestor_hex)
5866            .unwrap()
5867            .id();
5868        let tip_hex = make_tx_hex(&ancestor_txid, 0);
5869
5870        let storage = RecordingStorage {
5871            appended: Mutex::new(vec![]),
5872            request_once: Mutex::new(true),
5873            ancestor_outpoint: format!("{ancestor_txid}.0"),
5874        };
5875
5876        // No fetcher configured → default path → peer error must propagate.
5877        let gasp = GASPSync::new(
5878            Box::new(storage),
5879            Box::new(FailingNodeRemote),
5880            0,
5881            "[TEST]",
5882            true,
5883        );
5884
5885        let tip_node = GASPNode {
5886            graph_id: format!("{}.0", "deadbeef"),
5887            raw_tx: tip_hex,
5888            output_index: 0,
5889            proof: None,
5890            tx_metadata: None,
5891            output_metadata: None,
5892            inputs: None,
5893        };
5894
5895        let mut seen = std::collections::HashSet::new();
5896        let result = gasp.process_incoming_node(&tip_node, None, &mut seen).await;
5897        assert!(
5898            result.is_err(),
5899            "with no fetcher, the peer error must propagate (default path unchanged)"
5900        );
5901    }
5902
5903    #[tokio::test]
5904    async fn ancestor_fetcher_some_self_heals_missing_ancestor() {
5905        let ancestor_hex = make_tx_hex(
5906            "2222222222222222222222222222222222222222222222222222222222222222",
5907            0,
5908        );
5909        let ancestor_txid = bsv_rs::transaction::Transaction::from_hex(&ancestor_hex)
5910            .unwrap()
5911            .id();
5912        let tip_hex = make_tx_hex(&ancestor_txid, 0);
5913
5914        let storage = RecordingStorage {
5915            appended: Mutex::new(vec![]),
5916            request_once: Mutex::new(true),
5917            ancestor_outpoint: format!("{ancestor_txid}.0"),
5918        };
5919
5920        let fetcher = std::rc::Rc::new(MockFetcher {
5921            ancestor_hex: ancestor_hex.clone(),
5922            proof: None,
5923            called: Mutex::new(0),
5924        });
5925
5926        let gasp = GASPSync::new(
5927            Box::new(storage),
5928            Box::new(FailingNodeRemote),
5929            0,
5930            "[TEST]",
5931            true,
5932        )
5933        .with_ancestor_fetcher(Some(fetcher.clone()));
5934
5935        let tip_node = GASPNode {
5936            graph_id: "deadbeef.0".to_string(),
5937            raw_tx: tip_hex.clone(),
5938            output_index: 0,
5939            proof: None,
5940            tx_metadata: None,
5941            output_metadata: None,
5942            inputs: None,
5943        };
5944
5945        let mut seen = std::collections::HashSet::new();
5946        gasp.process_incoming_node(&tip_node, None, &mut seen)
5947            .await
5948            .expect("with fetcher, missing ancestor self-heals from chain");
5949
5950        assert_eq!(
5951            *fetcher.called.lock().unwrap(),
5952            2,
5953            "fetcher fires twice: once to hydrate the root's OWN proof (mock returns \
5954             proof:None here, so the root stays proofless + the walk continues), then \
5955             once to self-heal the missing ancestor (#126 root proof-anchoring)"
5956        );
5957
5958        // The synthesized ancestor node must have been appended to the graph,
5959        // with proof: None so the existing recursion would continue upward.
5960        // (We can't reach into the boxed storage here, but the Ok(()) above
5961        // proves the synthesized node flowed through process_incoming_node and
5962        // append_to_graph without error.)
5963    }
5964
5965    #[tokio::test]
5966    async fn ancestor_fetcher_some_skips_peer_request_node() {
5967        // Proof of the peer-skip optimization: with a fetcher present, the
5968        // doomed peer `request_node` call must NOT fire for ancestors — the
5969        // fetcher is consulted directly. (Otherwise a deep chain pays one
5970        // sequential peer round-trip per ancestor before falling back.)
5971        let ancestor_hex = make_tx_hex(
5972            "3333333333333333333333333333333333333333333333333333333333333333",
5973            0,
5974        );
5975        let ancestor_txid = bsv_rs::transaction::Transaction::from_hex(&ancestor_hex)
5976            .unwrap()
5977            .id();
5978        let tip_hex = make_tx_hex(&ancestor_txid, 0);
5979
5980        let storage = RecordingStorage {
5981            appended: Mutex::new(vec![]),
5982            request_once: Mutex::new(true),
5983            ancestor_outpoint: format!("{ancestor_txid}.0"),
5984        };
5985
5986        let counter = std::rc::Rc::new(Mutex::new(0u32));
5987        let remote = CountingNodeRemote {
5988            request_node_calls: counter.clone(),
5989        };
5990        let fetcher = std::rc::Rc::new(MockFetcher {
5991            ancestor_hex: ancestor_hex.clone(),
5992            proof: None,
5993            called: Mutex::new(0),
5994        });
5995
5996        let gasp = GASPSync::new(Box::new(storage), Box::new(remote), 0, "[TEST]", true)
5997            .with_ancestor_fetcher(Some(fetcher.clone()));
5998
5999        let tip_node = GASPNode {
6000            graph_id: "deadbeef.0".to_string(),
6001            raw_tx: tip_hex,
6002            output_index: 0,
6003            proof: None,
6004            tx_metadata: None,
6005            output_metadata: None,
6006            inputs: None,
6007        };
6008
6009        let mut seen = std::collections::HashSet::new();
6010        gasp.process_incoming_node(&tip_node, None, &mut seen)
6011            .await
6012            .expect("self-heal via fetcher should succeed");
6013
6014        assert_eq!(
6015            *counter.lock().unwrap(),
6016            0,
6017            "peer request_node must be SKIPPED for ancestors when a fetcher is present"
6018        );
6019        assert_eq!(
6020            *fetcher.called.lock().unwrap(),
6021            2,
6022            "fetcher fires twice: root's OWN proof hydration (#126) + serving the \
6023             ancestor directly (peer request_node still skipped)"
6024        );
6025    }
6026
6027    #[test]
6028    fn test_parse_outpoint() {
6029        let (txid, oi) = parse_outpoint("abc123.5").unwrap();
6030        assert_eq!(txid, "abc123");
6031        assert_eq!(oi, 5);
6032
6033        assert!(parse_outpoint("no_dot").is_none());
6034        assert!(parse_outpoint("abc.notanum").is_none());
6035    }
6036
6037    #[tokio::test]
6038    async fn test_gasp_storage_is_object_safe() {
6039        let storage: Box<dyn GASPStorage> = Box::new(MockGASPStorage::new(vec![]));
6040        let utxos = storage.find_known_utxos(0, None).await.unwrap();
6041        assert!(utxos.is_empty());
6042    }
6043
6044    #[tokio::test]
6045    async fn test_gasp_remote_is_object_safe() {
6046        let remote: Box<dyn GASPRemote> = Box::new(MockGASPRemote::new(vec![]));
6047        let response = remote
6048            .get_initial_response(&GASPInitialRequest {
6049                version: 1,
6050                since: 0,
6051                limit: None,
6052            })
6053            .await
6054            .unwrap();
6055        assert!(response.utxo_list.is_empty());
6056    }
6057
6058    #[tokio::test]
6059    async fn test_version_mismatch_error() {
6060        let remote = MockGASPRemote::new(vec![]);
6061        let result = remote
6062            .get_initial_response(&GASPInitialRequest {
6063                version: 99,
6064                since: 0,
6065                limit: None,
6066            })
6067            .await;
6068        assert!(result.is_err());
6069        assert!(matches!(
6070            result.unwrap_err(),
6071            GASPError::VersionMismatch { .. }
6072        ));
6073    }
6074}