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