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