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