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