Skip to main content

bsv_overlay_engine/
gasp.rs

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