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