bsv_overlay_engine/gasp.rs
1//! GASP — Graph Aware Sync Protocol.
2//!
3//! Defines the `GASPStorage` and `GASPRemote` traits, plus the `GASP` orchestrator
4//! that synchronizes overlay UTXOs between peers.
5//!
6//! Ported from:
7//! - `~/bsv/gasp-core/src/GASP.ts` (1,557 lines)
8//! - `~/bsv/overlay-services/src/GASP/OverlayGASPStorage.ts` (388 lines)
9//! - `~/bsv/overlay-services/src/GASP/OverlayGASPRemote.ts` (108 lines)
10
11use async_trait::async_trait;
12use tracing::{debug, error, info, warn};
13
14use crate::types::{
15 GASPInitialReply, GASPInitialRequest, GASPInitialResponse, GASPNode, GASPNodeResponse,
16 GASPOutput,
17};
18
19/// Current GASP protocol version.
20pub const GASP_VERSION: u32 = 1;
21
22/// Default sync limit per page.
23pub const DEFAULT_GASP_SYNC_LIMIT: u64 = 10000;
24
25/// Pull 100 outputs per request under the Worker's 30-second peer budget.
26/// Reference: overlay-services@480d77a src/Engine.ts:29,895 requests 10,000
27/// through src/GASP/OverlayGASPRemote.ts. Smaller requests bound each reply
28/// and allow a slow peer's completed prefix to persist across ticks.
29/// The responder's default stays unchanged for wire compatibility.
30pub const ENGINE_GASP_SYNC_PAGE_LIMIT: u64 = 100;
31
32// ============================================================================
33// Per-peer sync budgets + dead-peer quarantine (bsv-low#302)
34// ============================================================================
35
36/// PURE (bsv-low#257/#302): race `fut` against `deadline`; `None` = the
37/// deadline won and `fut` was DROPPED (its in-flight work cancelled).
38/// Injectable deadline so the control flow is natively unit-tested — the
39/// house idiom generalized out of `overlay-cloudflare`'s scheduled handler
40/// (which passes its platform `sleep_ms`) so the ENGINE's per-peer GASP
41/// budget can reuse it.
42pub async fn race_or_deadline<F, D, T>(fut: F, deadline: D) -> Option<T>
43where
44 F: std::future::Future<Output = T>,
45 D: std::future::Future<Output = ()>,
46{
47 let mut fut = std::pin::pin!(fut);
48 let mut deadline = std::pin::pin!(deadline);
49 std::future::poll_fn(move |cx| {
50 if let std::task::Poll::Ready(v) = fut.as_mut().poll(cx) {
51 return std::task::Poll::Ready(Some(v));
52 }
53 if deadline.as_mut().poll(cx).is_ready() {
54 return std::task::Poll::Ready(None);
55 }
56 std::task::Poll::Pending
57 })
58 .await
59}
60
61/// `deadline`, LATCHED: once it fell due it answers `Ready` on every poll and
62/// its inner future is never polled again (bsv-low #555, found by the
63/// delta-2 fold). The worker's sleeps are `async fn` futures, which PANIC
64/// when polled after they completed, and a per-graph deadline is polled
65/// again after it fell due (the shared resume deadline, kept for the next
66/// record; the half deadline of D2-L1).
67pub fn latched(deadline: crate::engine::SleepFuture) -> crate::engine::SleepFuture {
68 let mut inner = Some(deadline);
69 Box::pin(std::future::poll_fn(move |cx| {
70 let Some(deadline) = inner.as_mut() else {
71 return std::task::Poll::Ready(());
72 };
73 if deadline.as_mut().poll(cx).is_ready() {
74 inner = None;
75 return std::task::Poll::Ready(());
76 }
77 std::task::Poll::Pending
78 }))
79}
80
81/// What makes a deadline COOPERATIVE around a write (bsv-low #552, the lens
82/// fold's HIGH-1). `Engine::submit` is several storage writes (mark spent,
83/// delete the stale coin, insert the outputs, notify, record applied), each
84/// an await on D1, and a future dropped between two of them leaves a head
85/// chain with NO head: the old one deleted, the new one never inserted, and
86/// no applied row to say so.
87///
88/// The writer opens a [`WriteSection`] around ONE transaction's submit and
89/// asks [`SubmitGate::stop_if_due`] between two of them.
90/// [`race_or_deadline_guarded`] does not drop its future while a section is
91/// open: it notes the deadline as due and keeps polling until the section
92/// closes, so the wait is bounded by the writes of one transaction. What the
93/// deadline leaves is then a prefix of whole transactions, never part of one.
94/// The race does not bound a write that never answers: the writer does
95/// (bsv-low #559, `Engine::set_finalize_submit_budget`: the engine bounds
96/// each storage call and hook inside the section, and a call that never
97/// answers is that call's fault; the submit itself is never dropped).
98#[derive(Debug, Default)]
99pub struct SubmitGate {
100 writing: std::cell::Cell<u32>,
101 due: std::cell::Cell<bool>,
102}
103
104impl SubmitGate {
105 /// Open a section no guarded race may drop. Closed when the returned
106 /// value is dropped.
107 pub fn write_section(&self) -> WriteSection<'_> {
108 self.writing.set(self.writing.get() + 1);
109 WriteSection(self)
110 }
111
112 /// Whether a guarded race's deadline fell due while a section was open:
113 /// the race is waiting for the writer to stop.
114 pub fn deadline_is_due(&self) -> bool {
115 self.due.get()
116 }
117
118 /// The writer's question at a transaction boundary, with NO section open.
119 /// If a deadline is due this never returns: it hands the future back to
120 /// the race, which drops it on this very poll (only a guarded race sets
121 /// the flag, and it clears it when it ends).
122 pub async fn stop_if_due(&self) {
123 if self.due.get() {
124 std::future::pending::<()>().await;
125 }
126 }
127}
128
129/// An open write section of a [`SubmitGate`].
130#[must_use = "the section closes when this is dropped"]
131pub struct WriteSection<'g>(&'g SubmitGate);
132
133impl Drop for WriteSection<'_> {
134 fn drop(&mut self) {
135 self.0.writing.set(self.0.writing.get().saturating_sub(1));
136 }
137}
138
139/// [`race_or_deadline`] that never drops `fut` inside a [`WriteSection`] of
140/// `gate`. A deadline that falls due while a section is open is remembered
141/// (and shown to the writer, [`SubmitGate::deadline_is_due`]); `fut` is
142/// polled on, by its own wakers, and dropped at its first pending point
143/// outside a section. `None` then means what it meant: the deadline won.
144pub async fn race_or_deadline_guarded<F, D, T>(fut: F, deadline: D, gate: &SubmitGate) -> Option<T>
145where
146 F: std::future::Future<Output = T>,
147 D: std::future::Future<Output = ()>,
148{
149 // The flag never outlives the race that set it, however the race ends
150 // (an outer drop included): a stale one would park the next writer.
151 struct ClearDue<'g>(&'g SubmitGate);
152 impl Drop for ClearDue<'_> {
153 fn drop(&mut self) {
154 self.0.due.set(false);
155 }
156 }
157 let _clear = ClearDue(gate);
158 let mut fut = std::pin::pin!(fut);
159 let mut deadline = std::pin::pin!(deadline);
160 let mut due = false;
161 std::future::poll_fn(move |cx| {
162 if let std::task::Poll::Ready(v) = fut.as_mut().poll(cx) {
163 return std::task::Poll::Ready(Some(v));
164 }
165 if !due && deadline.as_mut().poll(cx).is_ready() {
166 due = true;
167 }
168 if due {
169 if gate.writing.get() == 0 {
170 return std::task::Poll::Ready(None);
171 }
172 // `fut` is parked inside a write: its own waker brings us back.
173 gate.due.set(true);
174 }
175 std::task::Poll::Pending
176 })
177 .await
178}
179
180/// Consecutive all-failure syncs after which a peer is QUARANTINED
181/// (bsv-low#302). At the production `*/15` cron cadence 8 consecutive
182/// failures ≈ 2 hours of unbroken unreachability — comfortably past any
183/// transient blip (deploy, restart, brief network fault, one bad tick),
184/// while still cutting a genuinely dead peer's budget burn off the same
185/// day it dies.
186pub const PEER_QUARANTINE_THRESHOLD: u64 = 8;
187
188/// Re-probe interval for a quarantined peer, seconds (6 h ≈ every 24 cron
189/// ticks). Quarantine is NEVER a deletion: once `secs_since_last_attempt`
190/// reaches this age the peer gets exactly ONE fresh sync attempt — success
191/// resets its failure count to 0 (full re-admission), failure re-arms the
192/// quarantine for another window. A peer that died transiently therefore
193/// re-admits itself automatically on the first re-probe after it recovers.
194pub const PEER_QUARANTINE_REPROBE_SECS: u64 = 6 * 3600;
195
196/// Consecutive YIELDLESS syncs of a (peer, topic) allowed before each further
197/// one is a FAILED attempt for the quarantine (bsv-low #555, the delta fold's
198/// D-M2). A sync is yieldless when the peer served it work it did not hold
199/// ([`GASPSync::graphs_attempted`] > 0) and it finalized no graph and moved no
200/// cursor; it then counts a success only because a walk PROGRESSED (a node
201/// appended), and a node is the peer's word: a hostile peer that lists one
202/// fresh fabricated UTXO a tick, serves its root and hangs on its input kept
203/// its slice for ever. Past this many in a row such a sync is a failure, so
204/// that peer is quarantined after this many plus
205/// [`PEER_QUARANTINE_THRESHOLD`] syncs (20, 5 h at `*/15`) and gets one probe
206/// per [`PEER_QUARANTINE_REPROBE_SECS`] after that. A sync that finalized a
207/// graph or moved the cursor resets the count; a quiet one leaves it.
208/// A quarantined peer is re-admitted only by such a yield (the delta-4
209/// fold's D4-L2): its sync that did not fail and yielded nothing, an empty
210/// listing at its probe included, is not recorded, so its failures stay and
211/// it is attended on each tick until it yields or fails again.
212///
213/// The bound is in SYNCS AND in TIME (the delta-2 fold's D2-M1): a yieldless
214/// sync is a failure only when the streak holds more than this many syncs
215/// AND began at least [`PEER_YIELDLESS_SECS_ALLOWED`] ago
216/// ([`yieldless_sync_failed`]). Counted in syncs alone, 12 was three hours at
217/// `*/15` and twelve minutes at LOW's one tick a minute, under the tail of a
218/// block interval: an honest peer whose only new work was #582's unproven
219/// head was quarantined while it waited for its block.
220///
221/// Why 12 and 3 h: an honest deep graph converges well inside both. The
222/// measured one (#582) is an UNPROVEN head over unproven ancestry: its root
223/// is re-asked at each resume and the pass after its block lands restarts the
224/// walk from a proven root and completes it (`root_proven`), one or two passes
225/// after a block that comes in ~10 min on average (past 3 h about once in
226/// 10^8 blocks by the exponential model, at any cadence). Walked to its end
227/// instead, 12 passes are about 150 nodes on the Cloudflare worker (15 s a
228/// pass at 1.2 s a request) and 600 on LOW's node (60 s). Applied only with a
229/// per-graph budget (`Engine::set_graph_budget`): without one no walk is
230/// deferred and the quarantine is the one of #302.
231pub const PEER_YIELDLESS_SYNCS_ALLOWED: u64 = 12;
232
233/// The AGE a yieldless streak must reach, seconds since its first yieldless
234/// sync, before a yieldless sync is a failure (3 h; the delta-2 fold's
235/// D2-M1, see [`PEER_YIELDLESS_SYNCS_ALLOWED`]). The storage keeps the
236/// streak's start ([`crate::storage::PeerYieldStreak`]) on its own clock.
237pub const PEER_YIELDLESS_SECS_ALLOWED: u64 = 3 * 3600;
238
239/// The DECAY of a yieldless streak (the delta-2 fold's D2-M1): a yieldless
240/// sync more than this long (6 h, [`PEER_QUARANTINE_REPROBE_SECS`]) after the
241/// streak's LAST yieldless sync starts a new streak (count 1, its start now)
242/// instead of adding to the old one. Quiet syncs in between leave the streak
243/// as it is (a quiet sync is not a yield), so an honest peer whose rare
244/// graphs that never land are hours apart is not counted across days. The
245/// cost, stated: a peer yieldless for 3 h, quiet for 6 h, and so on, keeps
246/// its slice during the yieldless hours (a third of its ticks at most), as
247/// #302's own accepted residual of one success in every eight.
248///
249/// Only for a peer that is NOT failed (`consecutive_failures` 0; the delta-3
250/// fold's D3-M1). This window equals the reprobe's, so the probe of a peer
251/// quarantined by this bound always came more than 6 h after its last
252/// yieldless sync: it started a fresh streak (count 1, not failed), the sync
253/// "progressed", the failures went to 0 and the quarantine lifted at every
254/// probe (half the ticks at `*/15`). The probe of a failed peer continues
255/// its streak and fails, so it gets one probe per reprobe window.
256pub const PEER_YIELDLESS_DECAY_SECS: u64 = PEER_QUARANTINE_REPROBE_SECS;
257
258/// PURE rule of the yieldless bound (bsv-low #555, the delta-2 fold's
259/// D2-M1): a yieldless sync is a FAILED attempt IFF its streak holds more
260/// than [`PEER_YIELDLESS_SYNCS_ALLOWED`] syncs AND is at least
261/// [`PEER_YIELDLESS_SECS_ALLOWED`] old. A streak of unknown age (a backend
262/// with no clock) is never past (fail-safe, as #302's unknown attempt age).
263pub fn yieldless_sync_failed(streak: &crate::storage::PeerYieldStreak) -> bool {
264 streak.yieldless_syncs > PEER_YIELDLESS_SYNCS_ALLOWED
265 && streak
266 .secs_since_first
267 .is_some_and(|secs| secs >= PEER_YIELDLESS_SECS_ALLOWED)
268}
269
270/// The NORMALIZED ORIGIN of a peer URL (bsv-low #555, the delta-2 fold's
271/// D2-M2): its host, lowercased, with no scheme, user, port, path, query or
272/// fragment, and no trailing dot. The key of a peer's quarantine and
273/// yieldless streak (the engine passes it to the three peer-health methods
274/// of [`crate::storage::Storage`]) and of its share of a storage's deferred
275/// graph ceiling (the worker's `origin` column): keyed on the URL string, one
276/// server was eight "hosts" for eight adverts (`?1`, `:8443`, a trailing
277/// slash, `#x`), each with its own share and a fresh streak. A subdomain is
278/// another host still (no public suffix list here): stated. The cursor and
279/// the deferred records stay keyed by the URL the peer is synced at.
280pub fn peer_origin(url: &str) -> String {
281 peer_authority(url).1
282}
283
284/// `(scheme, host, port)` of a peer URL: the scheme lowercased (`https`
285/// when absent), the host as [`peer_origin`] gives it, the port as written
286/// (`None` when absent, empty, or the scheme's default: `443` for `https`,
287/// `80` for `http`).
288fn peer_authority(url: &str) -> (String, String, Option<String>) {
289 let url = url.trim();
290 let (scheme, rest) = url.split_once("://").unwrap_or(("https", url));
291 let scheme = scheme.to_ascii_lowercase();
292 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
293 let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
294 let (host, port) = match host_port.strip_prefix('[') {
295 // `[::1]:8443`: the bracketed address, brackets kept.
296 Some(v6) => match v6.split_once(']') {
297 Some((addr, after)) => (format!("[{addr}]"), after.strip_prefix(':')),
298 None => (host_port.to_string(), None),
299 },
300 None => match host_port.split_once(':') {
301 Some((host, port)) => (host.to_string(), Some(port)),
302 None => (host_port.to_string(), None),
303 },
304 };
305 let default_port = match scheme.as_str() {
306 "https" => "443",
307 "http" => "80",
308 _ => "",
309 };
310 let port = port.filter(|p| !p.is_empty() && *p != default_port);
311 (
312 scheme,
313 host.trim_end_matches('.').to_ascii_lowercase(),
314 port.map(str::to_string),
315 )
316}
317
318/// The peers of a SHIP-discovered topic, ONE per [`peer_origin`] (bsv-low
319/// #555, the delta-2 fold's D2-M2; restored by the delta-4 fold, D4-M1): each
320/// advertised domain is canonicalized to `scheme://host[:port]` (the scheme
321/// and host lowercased; path, query, fragment, user, a trailing dot and the
322/// scheme's default port dropped), and of the spellings of one origin the
323/// first by `https`, then no explicit port, then the string is kept. So
324/// eight adverts of one server (`/?1`, `/?2`, `/#x`, `:8443`, ...) are one
325/// peer synced once a tick, and a stranger's spelling of an honest host (a
326/// query, a dead port) cannot stand beside that host's own advert under its
327/// quarantine key. The delta-3 fold kept one peer per `scheme://host[:port]`
328/// while the failures stayed on one row per origin (D4-M1): eight adverts of
329/// dead ports of an honest host's name quarantined the honest peer, and
330/// seven were never quarantined, a slice each on every tick. The LIMIT,
331/// stated (the delta-3 lens's D3-L1): two overlays of one host on two ports,
332/// on one SHIP topic, are one peer (the default port's), so a stranger's
333/// default-port spelling displaces an honest overlay that serves only on
334/// another port. Sorted, for a stable order.
335pub fn ship_peers_by_origin<I: IntoIterator<Item = String>>(domains: I) -> Vec<String> {
336 type Rank = (bool, bool, String);
337 let mut by_origin: std::collections::BTreeMap<String, (Rank, String)> =
338 std::collections::BTreeMap::new();
339 for domain in domains {
340 let (scheme, origin, port) = peer_authority(&domain);
341 if origin.is_empty() {
342 continue;
343 }
344 let canonical = match &port {
345 Some(port) => format!("{scheme}://{origin}:{port}"),
346 None => format!("{scheme}://{origin}"),
347 };
348 let candidate = (scheme != "https", port.is_some(), canonical.clone());
349 match by_origin.get(&origin) {
350 Some((kept, _)) if *kept <= candidate => {}
351 _ => {
352 by_origin.insert(origin, (candidate, canonical));
353 }
354 }
355 }
356 let mut peers: Vec<String> = by_origin.into_values().map(|(_, url)| url).collect();
357 peers.sort();
358 peers
359}
360
361/// PURE quarantine rule (bsv-low#302): a peer is skipped IFF it has hit
362/// [`PEER_QUARANTINE_THRESHOLD`] consecutive failures AND its last attempt
363/// is younger than [`PEER_QUARANTINE_REPROBE_SECS`]. A peer with no
364/// recorded attempt (`secs_since_last_attempt == None` — pristine, or a
365/// backend without peer-health tracking) is NEVER quarantined, and an old
366/// enough last attempt always re-opens one probe — fail-safe in the
367/// "attempt more, never strand a peer forever" direction.
368///
369/// ACCEPTED RESIDUAL (bsv-low#304 gate M-3, documented — not built): a
370/// peer that ALTERNATES one success into every window of ≤7 failures
371/// resets its streak each time and is never quarantined, burning up to one
372/// per-peer budget slice (30 s) per (host, topic) per tick forever. Why
373/// accepted: (a) the burn is bounded by the per-peer budget times the
374/// configured peer set, all inside the 240 s outer step belt — the #257
375/// unbounded-hang class cannot recur; (b) sustaining it requires the peer
376/// to actually COMPLETE a real sync every few ticks, i.e. behave as a
377/// (slow) live peer — indistinguishable in principle from a genuinely
378/// flaky honest peer, which the rule must never strand; (c) LOW's money
379/// topics do not GASP-sync at all (no `sync_configuration` peers in prod),
380/// so the exposure is cron-budget noise, not a money surface. A
381/// streak-decay or success-ratio rule could narrow it later if a real
382/// abuser appears.
383pub fn peer_sync_quarantined(health: &crate::storage::PeerSyncHealth) -> bool {
384 health.consecutive_failures >= PEER_QUARANTINE_THRESHOLD
385 && health
386 .secs_since_last_attempt
387 .is_some_and(|secs| secs < PEER_QUARANTINE_REPROBE_SECS)
388}
389
390// ============================================================================
391// Deferred graphs (bsv-low #555)
392// ============================================================================
393
394/// Default per-GRAPH call budget (bsv-low #555): requests to the peer, or
395/// chain fetches, that ONE graph's walk may make in one pass before it is
396/// deferred. With [`DEFAULT_GRAPH_BUDGET_MS`]: at the 1.2 s per request
397/// measured on beta (2026-10-09, #582) 100 calls take about 120 s, so on a
398/// slow peer the time binds first (about 50 nodes a pass) and on a fast one
399/// the calls do, which also bounds how much a record grows per pass.
400pub const DEFAULT_GRAPH_BUDGET_CALLS: u32 = 100;
401
402/// Default per-GRAPH time budget, ms (bsv-low #555): half of the 120 s
403/// topic slice of LOW's node (one peer), so a deep graph takes at most half
404/// of its topic's slice and the UTXOs after it keep the other half. A caller
405/// keeps it BELOW its per-peer budget (`Engine::set_peer_sync_budget`); a
406/// graph the per-peer deadline cuts first is deferred all the same.
407pub const DEFAULT_GRAPH_BUDGET_MS: u64 = 60_000;
408
409/// Default per-GRAPH bytes budget (bsv-low #586): the bytes of the nodes ONE
410/// graph's walk may be SERVED in one pass before it is deferred, counted as
411/// the peer serves them and as a record keeps them (the hex of each node's
412/// raw transaction and of its proof, every answer counted, a repeat too).
413/// A BUDGET PER PASS, not a limit: the walk makes at least one request a
414/// pass whatever its size, and what the pass leaves is resumed.
415///
416/// 917,504 bytes: a budget per pass and nothing else (bsv-low #585). It was
417/// derived from a 1 MiB cap on a record (seven eighths of it, the lens fold's
418/// E586-L1) so that the pass it cut left a record that fitted; a record has
419/// no cap since #585 (the storage keeps one of any size, the worker in
420/// chunks), so the figure is kept for what it measures, 18 heads of 26 KB a
421/// fresh pass, and no longer sits "under" anything. A consumer may set any
422/// other ([`GraphBudget::max_bytes_fetched`]).
423pub const DEFAULT_GRAPH_BUDGET_BYTES: u64 = 917_504;
424
425/// Default per-GRAPH node budget (bsv-low #586): the nodes ONE graph's walk
426/// may APPEND in one pass before it is deferred (five times the 12 of
427/// Zanaadu's largest legitimate graph). As [`DEFAULT_GRAPH_BUDGET_BYTES`], a
428/// budget per pass that resumes; it also bounds the JSON a pass adds to a
429/// record around its nodes' hex, which the bytes limb does not count.
430pub const DEFAULT_GRAPH_BUDGET_NODES: u32 = 64;
431
432/// Reset a partial walk after this many passes (`max_passes`). If its cursor
433/// could have passed, keep a root retry obligation in the same bounded slot.
434/// One hour at a one-minute cadence, fifteen at `*/15`.
435pub const DEFERRED_GRAPH_MAX_PASSES: u32 = 60;
436
437/// The most records one (peer, topic) may hold. A new deferral past it is
438/// not saved (reason `too_many`) and its walk goes on under the per-peer
439/// budget alone, as before #555 (the lens fold's L4): a peer that serves many
440/// deep graphs costs at most 16 records. A storage may refuse a record at a
441/// ceiling of its own ([`DeferredGraphSave::AtCeiling`]), counted `too_many`
442/// too (the worker's global ceiling, the lens fold's M3). A record has NO
443/// byte bound (bsv-low #585: it had 1 MiB, `too_big`): a graph of any size
444/// is deferred and resumed whole, and the storage's ceiling is a BUDGET of
445/// room, under which a RESUMED walk keeps the record it holds.
446pub const DEFERRED_GRAPHS_PER_PEER_TOPIC: usize = 16;
447
448/// Reset a resumed partial walk after this many consecutive idle faults.
449/// Keep the root retry obligation if the cursor could have passed it. These
450/// attempts remain yieldless and count toward peer quarantine.
451pub const DEFERRED_GRAPH_MAX_IDLE_FAULTS: u32 = 3;
452
453/// Three consecutive cuts by the graph's OWN deadline (`time`) distinguish a
454/// repeatedly stalled root from a single slow response. Keep a bounded retry
455/// record meanwhile; only the third cut permits the cursor to pass a root
456/// with no fetched node. The per-PEER deadline (`peer_deadline`) is not the
457/// graph's: the graph in flight when the peer's slice ends is held at once
458/// (see [`GASPSync::defer_in_flight`]).
459pub const DEADLINE_STALL_DEFERRAL_RUNS: u32 = 3;
460
461/// How long a backend remembers an outpoint the walk refused as expired,
462/// past `max(expiry, refusal)`: seven days (engine 0.1.8). A peer keeps
463/// listing a dead advert while it is unspent there, so without the memo
464/// every run fetched its node to refuse it again (fact 3 of the 0.1.8
465/// brief: about 470 node fetches, most of a 30 s slice). With it, one fetch
466/// a week per dead outpoint; the sweep bounds the table by age.
467pub const EXPIRED_OUTPOINT_MEMO_SECS: u64 = 7 * 24 * 3600;
468
469/// The most expired roots a backend remembers for one topic: about nine
470/// times the 2,282 dead adverts of the old overlay's UHRP history. Past it
471/// the oldest rows go; a forgotten one costs one node fetch again.
472pub const EXPIRED_OUTPOINT_MEMO_MAX_ROWS: usize = 20_000;
473
474/// A root whose walk is dropped by a FAULT (not a deadline, not a bound) in
475/// this many consecutive runs is recorded unanchorable (engine 0.1.9): a
476/// record `unanchorable:<class>` the cursor passes, re-walked on the long
477/// backoff [`UNANCHORABLE_RETRY_SECS`]. Three, the deadline rule's number
478/// ([`DEADLINE_STALL_DEFERRAL_RUNS`]): one fault is the moment's (a 5xx, a
479/// rung's rate limit, a courier budget earlier graphs spent); a fault that
480/// survives three runs, each with a fresh peer slice and a fresh courier
481/// budget and each walking this root ahead of the pages, is the graph's own.
482/// Before 0.1.9 such a root held the cursor below it for ever (the 0.1.4
483/// rule) and every later output waited on a captain's hand. A held record
484/// whose RESUMED pass is dropped by such a fault counts the same runs on its
485/// own slot (engine 0.1.11): on 0.1.10 a root retry obligation whose parent
486/// no rung served was dropped and saved again as it was on every run, never
487/// struck (fact 3 of the 0.1.11 brief).
488pub const UNANCHORABLE_AFTER_FAULTED_RUNS: u32 = 3;
489
490/// How long an unanchorable root waits before its next walk: six hours. What
491/// it lacks lands on that scale or not at all: an ancestor a rung indexes
492/// once mined (minutes to an hour), a peer that gains the history, a proof.
493/// Four walks a day of each of at most [`DEFERRED_GRAPHS_PER_PEER_TOPIC`]
494/// records is 64 walks a day for a (peer, topic) at worst, against 1,440
495/// ticks a day at a one-minute cadence. A later success admits it as any
496/// root and clears the record; nothing is admitted unanchored (T2).
497pub const UNANCHORABLE_RETRY_SECS: u64 = 6 * 3600;
498
499/// Four retries, six hours apart, cover one day after an unanchorable verdict.
500/// A spent allowance is a durable fact until a new score or operator reset.
501pub const UNANCHORABLE_RETRIES: u32 = 4;
502pub const DEAD_REASON: &str = "dead:";
503
504/// The least time between two counted absences of one root (engine 0.1.15,
505/// bsv-overlay-cloudflare #56): an absence is the advertising peer's word
506/// ([`fault_class::NOT_FOUND`]), and three of them strike the root
507/// `unanchorable:not_found` only when they were asked on three runs with a
508/// gap. Sixty seconds: two of the stack's 30 s per-peer slices
509/// (`GASP_PEER_SYNC_BUDGET_MS`), one tick at its one-minute cadence, so a
510/// backfill call walking slice after slice cannot count one root three
511/// times in one breath. Until it passes the root's record costs no request
512/// and the cursor holds below it, as for any fault under the threshold.
513pub const ABSENCE_GAP_SECS: u64 = 60;
514
515/// A held (passable) record resumed this many CONSECUTIVE passes with no
516/// node appended goes to the long backoff (engine 0.1.10): it is not resumed
517/// again until [`IDLE_RESUME_RETRY_SECS`] have passed, so the resumes' shared
518/// budget goes to records that progress and the peer's slice to its page.
519/// Three, the deadline rule's number ([`DEADLINE_STALL_DEFERRAL_RUNS`]) and
520/// the idle-fault rule's ([`DEFERRED_GRAPH_MAX_IDLE_FAULTS`]): one empty
521/// pass is the moment's (a slow peer, a budget the earlier resumes spent),
522/// three in a row, each with a fresh slice, are the record's own. On 0.1.9 a
523/// root retry obligation the peer no longer serves (fact 3 of the 0.1.10
524/// brief: 14 of them, `nodes 0`, `not_held` on every run) was resumed on
525/// every tick for ever, and they took the slice the page's fresh roots
526/// needed. A resume that appends a node resets the count; one past the
527/// backoff that appends nothing goes back to it at once.
528pub const IDLE_RESUMES_BEFORE_BACKOFF: u32 = 3;
529
530/// How long a record sent to the long backoff waits before its next resume:
531/// [`UNANCHORABLE_RETRY_SECS`] (six hours), as an unanchorable root does, for
532/// the same reason: what it lacks (a peer that serves the root again, a
533/// proof, an ancestor) lands on that scale or not at all. The record is
534/// kept (bsv-low #555's resume: its cursor may have passed it) and costs
535/// four resumes a day instead of 1,440.
536pub const IDLE_RESUME_RETRY_SECS: u64 = UNANCHORABLE_RETRY_SECS;
537
538/// The share of the per-PEER sync budget the resumes of one pass may take
539/// together, in percent (engine 0.1.11): the held records are walked ahead
540/// of the page, so without a share of its own the page got what they left.
541/// The rest of the slice is the page's.
542///
543/// 33: on the stack's worker the per-peer budget is 30,000 ms
544/// (`GASP_PEER_SYNC_BUDGET_MS`) and one graph's own budget 15,000 ms
545/// (`GASP_GRAPH_BUDGET_MS`), which was the resumes' shared deadline (the lens
546/// fold's M1) and started at the opening resume, so the reads and writes of
547/// the storage between resumes (a record read, its save or drop, the
548/// settled read: about five a record) came on top of it, outside any race.
549/// The share is [`resume_share_ms`]: `min(30,000 * 33 / 100, 15,000)` =
550/// 9,900 ms, started when the held loop starts, so those storage calls are
551/// inside it. The page keeps 20,100 ms: one fresh graph's whole 15,000 ms
552/// budget and 5,100 ms for its listing pages, the finalize submits and the
553/// storage. The courier's rungs (bitails, woc, bananablocks, asked in turn)
554/// have no timeout of their own (the worker's `http_get` sets none): a rung
555/// is cut only by the deadline its step races, which inside the held loop is
556/// this share. The worker sizes a courier attempt at 500 ms
557/// (`GASP_PROOF_FETCH_BUDGET`, a planning figure, not a measurement), so the
558/// share holds about 20 attempts: about 6 resumes of fact 3's shape (a root
559/// request, a root proof ask and an ancestor's three rungs), the rest held
560/// back to the next run. A record held back by the share is not resumed, not
561/// a failed UTXO, not a stalled walk and not an idle resume.
562pub const RESUMES_SHARE_OF_PEER_BUDGET_PERCENT: u64 = 33;
563
564/// The resumes' share of a pass, ms (engine 0.1.11): the
565/// [`RESUMES_SHARE_OF_PEER_BUDGET_PERCENT`] of the per-peer budget, never
566/// more than one graph's own budget (the shared deadline before 0.1.11).
567pub fn resume_share_ms(peer_budget_ms: u64, graph_budget_ms: u64) -> u64 {
568 (peer_budget_ms.saturating_mul(RESUMES_SHARE_OF_PEER_BUDGET_PERCENT) / 100).min(graph_budget_ms)
569}
570
571/// The reason prefix of a root's record while its fault streak is under
572/// [`UNANCHORABLE_AFTER_FAULTED_RUNS`]: not passable, the cursor holds.
573pub const FAULT_RETRY_REASON: &str = "retry:fault:";
574
575/// The reason prefix of an unanchorable root's record: passable.
576pub const UNANCHORABLE_REASON: &str = "unanchorable:";
577
578/// The fault classes a dropped root is named with (engine 0.1.9).
579pub mod fault_class {
580 /// The courier (the chain ancestor fetcher, its rungs) faulted or could
581 /// not serve an ancestor.
582 pub const COURIER: &str = "courier";
583 /// Absence (engine 0.1.15, bsv-overlay-cloudflare #56): the advertising
584 /// peer answered it does not hold the root, or a transaction an
585 /// UNPROVEN node of the graph needs (`NodeNotFound`), so nothing can
586 /// anchor; or, beside it, every configured raw rung answered absent
587 /// (`DefinitiveNotFound`). One run counts once whoever said it. The
588 /// peers' word decides: a rung, when one is configured, fetches and
589 /// never strikes by itself (on 0.1.12 to 0.1.14 the rungs' absence
590 /// struck `unanchorable:not_found` at once). Three runs at least
591 /// [`super::ABSENCE_GAP_SECS`] apart strike it, as any striking class.
592 pub const NOT_FOUND: &str = "not_found";
593 /// The class a peer's `NodeNotFound` carried on 0.1.9 to 0.1.14. Kept
594 /// so the records written then read as they were; a peer's word of
595 /// absence is [`NOT_FOUND`] since 0.1.15.
596 pub const NOT_SERVED: &str = "not_served";
597 /// The peer faulted serving a node (a transport fault, a 5xx, a body
598 /// that does not parse). Named, never a strike: a peer that answers
599 /// with faults is the quarantine's to judge (bsv-low #302, #555's D-M1:
600 /// a 5xx keeps no record), not a reason to pass the roots it lists.
601 pub const PEER: &str = "peer";
602 /// A served node was refused when appended to the graph (it does not
603 /// parse or verify as the graph needs it).
604 pub const VERIFICATION: &str = "verification";
605 /// Any other failure (a storage fault of ours, a bound): named, never a
606 /// strike against the root.
607 pub const OTHER: &str = "other";
608 /// The route to the peer faulted before the peer was reached (engine
609 /// 0.2.0, #57; `GASPError::RouteFault`): named, never a strike, as it
610 /// says nothing of the root or of the peer.
611 pub const ROUTE: &str = "route";
612
613 /// Whether a fault of `class` counts a faulted run of its root: the
614 /// three of the 0.1.9 brief (the courier, an input nobody served, a
615 /// verification refusal), never a peer's fault of the moment.
616 pub fn strikes(class: &str) -> bool {
617 matches!(class, COURIER | NOT_SERVED | VERIFICATION | NOT_FOUND)
618 }
619}
620
621/// One root a sync dropped, named with why (engine 0.1.9): what the sync's
622/// accounting and the tick's summary line carry, once per (peer, root) per
623/// run.
624#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
625pub struct FaultedRoot {
626 /// The peer, filled by the engine (empty inside one `GASPSync`).
627 #[serde(default)]
628 pub peer: String,
629 /// The root outpoint.
630 pub outpoint: String,
631 /// Its class, one of [`fault_class`].
632 pub class: String,
633 /// The consecutive faulted runs its record counts (0: not recorded).
634 pub faults: u32,
635 /// Whether it is recorded unanchorable (the cursor passes it).
636 pub unanchorable: bool,
637 /// The error, cut to 160 characters.
638 pub error: String,
639 /// Why its strike was refused: `no_room` at the storage's own ceiling,
640 /// or `store_fault`; empty when written or no strike was due.
641 #[serde(default, skip_serializing_if = "String::is_empty")]
642 pub refused: String,
643}
644
645/// Why a strike was refused (engine 0.1.10), as [`FaultedRoot::refused`]
646/// names it.
647pub mod strike_refused {
648 /// No room at the storage's own ceiling. Memo eviction is not a refusal.
649 pub const NO_ROOM: &str = "no_room";
650 /// The storage faulted writing the record.
651 pub const STORE_FAULT: &str = "store_fault";
652}
653
654/// The per-graph budget of a [`GASPSync`] (bsv-low #555,
655/// `Engine::set_graph_budget`). Setting one turns deferral ON for that sync.
656pub struct GraphBudget<'a> {
657 /// Calls one graph may make in one pass ([`DEFAULT_GRAPH_BUDGET_CALLS`]).
658 pub max_calls: u32,
659 /// Bytes one graph may be served in one pass
660 /// ([`DEFAULT_GRAPH_BUDGET_BYTES`], bsv-low #586). Reached, the graph is
661 /// DEFERRED as at `max_calls` (reason `bytes`): never dropped, never
662 /// refused.
663 pub max_bytes_fetched: u64,
664 /// Nodes one graph may append in one pass
665 /// ([`DEFAULT_GRAPH_BUDGET_NODES`], bsv-low #586). Reached, the graph is
666 /// deferred (reason `nodes`).
667 pub max_nodes: u32,
668 /// A fresh deadline for ONE graph's pass ([`DEFAULT_GRAPH_BUDGET_MS`]).
669 pub deadline: Box<dyn Fn() -> crate::engine::SleepFuture + 'a>,
670 /// A fresh deadline for HALF of it (the delta-2 fold's D2-L1): a FRESH
671 /// walk that faults once this fell due, or after half its calls, has
672 /// paid for a record and keeps one. `None`: the calls alone decide.
673 pub half_deadline: Option<Box<dyn Fn() -> crate::engine::SleepFuture + 'a>>,
674}
675
676impl GraphBudget<'_> {
677 /// What one pass of one graph may spend.
678 fn pass(&self) -> PassCap {
679 PassCap {
680 calls: self.max_calls,
681 bytes: self.max_bytes_fetched,
682 nodes: self.max_nodes,
683 }
684 }
685}
686
687/// What one pass of a graph's walk may still spend of the per-graph budget's
688/// three counted limbs (its time is a deadline): calls made, bytes served,
689/// nodes appended.
690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
691struct PassCap {
692 calls: u32,
693 bytes: u64,
694 nodes: u32,
695}
696
697impl PassCap {
698 /// No bound: a sync with no per-graph budget, or a walk that goes on
699 /// under the per-peer budget alone (L4).
700 const NONE: PassCap = PassCap {
701 calls: u32::MAX,
702 bytes: u64::MAX,
703 nodes: u32::MAX,
704 };
705
706 /// Whether a limb is spent to nothing.
707 fn spent(self) -> bool {
708 self.calls == 0 || self.bytes == 0 || self.nodes == 0
709 }
710}
711
712/// The key of one held record (bsv-low #555): what a sync loads up front.
713/// The records resume independently of the peer's listing, one at a time
714/// ([`GASPStorage::get_deferred_graph`]), so at most one record is in memory
715/// at a time (the lens fold's L3: the up-front load held up to 16 of them).
716#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
717#[serde(rename_all = "camelCase")]
718pub struct DeferredGraphKey {
719 /// The root UTXO, `txid.outputIndex`.
720 pub outpoint: String,
721 /// The root UTXO's score at the peer.
722 pub score: u64,
723}
724
725/// What a storage did with a record it was asked to save (bsv-low #555).
726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
727pub enum DeferredGraphSave {
728 /// Saved (replaced).
729 Saved,
730 /// Refused at a ceiling of the storage's own (the worker's global count
731 /// and byte bounds, the lens fold's M3): counted `too_many`, and the walk
732 /// goes on under the per-peer budget alone, as before #555. A BUDGET
733 /// signal, never the loss of a walk (bsv-low #585): the storage must
734 /// leave the record it HELD under that key as it was, and the engine
735 /// keeps it, so a resumed graph refused room resumes from that record at
736 /// the next pass and never walks from its root again.
737 AtCeiling,
738}
739
740/// One node a deferred walk has fetched and appended, in append order.
741#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
742#[serde(rename_all = "camelCase")]
743pub struct WalkedNode {
744 /// The node as it was appended (the root's proof hydrated, if it was).
745 pub node: GASPNode,
746 /// `txid.outputIndex` of the node that spends it; `None` for the root.
747 pub spent_by: Option<String>,
748}
749
750/// One input a deferred walk has still to ask for.
751#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
752#[serde(rename_all = "camelCase")]
753pub struct PendingInput {
754 /// `txid.outputIndex` of the input (for the root, the UTXO itself).
755 pub outpoint: String,
756 /// The graph id the request carries (the root's outpoint).
757 pub graph_id: String,
758 /// Whether the input's metadata is asked for.
759 pub metadata: bool,
760 /// `txid.outputIndex` of the node that needs it; `None` for the root.
761 pub spent_by: Option<String>,
762 /// Whether that node is PROVEN: a named input the peer does not hold is
763 /// then pruned (the D8 rule), else it fails the graph.
764 pub parent_proven: bool,
765}
766
767/// The persisted partial walk of ONE graph (bsv-low #555): one record per
768/// (peer, topic, root outpoint), REPLACED on every deferral, never appended.
769#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
770#[serde(rename_all = "camelCase")]
771pub struct DeferredGraph {
772 /// The peer the graph is walked from.
773 pub peer: String,
774 /// The topic.
775 pub topic: String,
776 /// The root UTXO, `txid.outputIndex`.
777 pub outpoint: String,
778 /// The root UTXO's score at the peer.
779 pub score: u64,
780 /// The nodes fetched so far, with their proofs, in append order.
781 pub nodes: Vec<WalkedNode>,
782 /// The inputs still to ask for, a stack (the last is asked next).
783 pub pending: Vec<PendingInput>,
784 /// Calls spent on this graph over every pass.
785 pub calls: u64,
786 /// Passes that deferred it (its age, in passes).
787 pub passes: u32,
788 /// Why it was last deferred: `calls`, `time`, `bytes`, `nodes`, `fault`,
789 /// `anchor_unavailable`, `anchor_walk_held` (the E592 lens fold: the
790 /// anchor's walk past the budget, held until the root is served proven),
791 /// `not_landed` or `peer_deadline`; for a root whose walk faulted
792 /// (engine 0.1.9) `retry:fault:<class>` under
793 /// [`UNANCHORABLE_AFTER_FAULTED_RUNS`] runs, `unanchorable:<class>` at it.
794 pub reason: String,
795 /// Whether the peer is a CONFIGURED one (`SyncTarget::Peers`), not one
796 /// `ls_ship` discovered (bsv-low #555, the delta-2 fold's D2-M2): a
797 /// storage reserves part of its ceiling for these (the worker: records
798 /// of discovered peers hold at most half of it). Set by the storage
799 /// adapter on every save; `false` in a record saved before the fold.
800 #[serde(default)]
801 pub configured: bool,
802 /// Consecutive RESUMED passes that faulted having appended no node
803 /// (the delta-2 fold's D2-M2): the record is dropped (`idle_faults`)
804 /// when it reaches [`DEFERRED_GRAPH_MAX_IDLE_FAULTS`]; a pass that
805 /// appends a node resets it.
806 #[serde(default)]
807 pub idle_faults: u32,
808 /// The cursor may pass this durable obligation. A false value also reads
809 /// records written before independent resume support.
810 #[serde(default)]
811 pub cursor_passable: bool,
812 /// Consecutive runs whose walk of this root faulted (engine 0.1.9): a
813 /// record `retry:fault:<class>` under [`UNANCHORABLE_AFTER_FAULTED_RUNS`],
814 /// `unanchorable:<class>` at it. 0 in every other record (and not
815 /// written, so their JSON is unchanged).
816 #[serde(default, skip_serializing_if = "is_zero_u32")]
817 pub faults: u32,
818 /// Unix seconds before which an unanchorable root is not walked again
819 /// ([`UNANCHORABLE_RETRY_SECS`]); 0 otherwise.
820 #[serde(default, skip_serializing_if = "is_zero_u64")]
821 pub retry_at: u64,
822 /// Consecutive passes that RESUMED this held record and appended no node
823 /// (engine 0.1.10): at [`IDLE_RESUMES_BEFORE_BACKOFF`] the record waits
824 /// [`IDLE_RESUME_RETRY_SECS`] (its `retry_at`) before its next resume; a
825 /// pass that appends a node resets it. 0 in every other record (and not
826 /// written, so their JSON is unchanged).
827 #[serde(default, skip_serializing_if = "is_zero_u32")]
828 pub idle_resumes: u32,
829 /// Retry reservations made after the unanchorable verdict, including cuts.
830 #[serde(default, skip_serializing_if = "is_zero_u32")]
831 pub unanchorable_retries: u32,
832 /// Advert expiry read by the topic manager from the content-addressed root.
833 #[serde(default, skip_serializing_if = "Option::is_none")]
834 pub expiry: Option<u64>,
835}
836
837// serde's `skip_serializing_if` passes the field by reference.
838#[allow(clippy::trivially_copy_pass_by_ref)]
839fn is_zero_u32(n: &u32) -> bool {
840 *n == 0
841}
842
843#[allow(clippy::trivially_copy_pass_by_ref)]
844fn is_zero_u64(n: &u64) -> bool {
845 *n == 0
846}
847
848impl DeferredGraph {
849 /// Whether this record counts a root's faulted runs (engine 0.1.9).
850 pub fn is_struck(&self) -> bool {
851 self.faults > 0
852 && (self.reason.starts_with(FAULT_RETRY_REASON)
853 || self.reason.starts_with(UNANCHORABLE_REASON)
854 || self.reason.starts_with(DEAD_REASON))
855 }
856
857 /// Whether this record holds an unanchorable root (passable).
858 pub fn is_unanchorable(&self) -> bool {
859 self.is_struck() && self.reason.starts_with(UNANCHORABLE_REASON)
860 }
861
862 pub fn is_dead(&self) -> bool {
863 self.is_struck() && self.reason.starts_with(DEAD_REASON)
864 }
865
866 /// Whether this held record waits out the long backoff of its idle
867 /// resumes at `now` (engine 0.1.10): not resumed, the cursor passes it.
868 /// A fault record keeps its own backoff, a record that holds the cursor
869 /// is never backed off.
870 pub fn waits_idle(&self, now: u64) -> bool {
871 !self.is_struck() && self.cursor_passable && now < self.retry_at
872 }
873
874 /// The fault class the record names, if it counts faulted runs.
875 pub fn fault_class(&self) -> Option<&str> {
876 if !self.is_struck() {
877 return None;
878 }
879 self.reason
880 .strip_prefix(DEAD_REASON)
881 .or_else(|| self.reason.strip_prefix(UNANCHORABLE_REASON))
882 .or_else(|| self.reason.strip_prefix(FAULT_RETRY_REASON))
883 }
884
885 /// The record's size as stored (its JSON): what the deferral's log line
886 /// and a storage's byte budget read. Nothing refuses a record for it
887 /// (bsv-low #585).
888 pub fn byte_size(&self) -> usize {
889 serde_json::to_vec(self).map_or(usize::MAX, |b| b.len())
890 }
891}
892
893/// Why a deferred graph's record was deleted without converging.
894#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
895#[serde(rename_all = "snake_case")]
896pub enum DropReason {
897 /// Deferred [`DEFERRED_GRAPH_MAX_PASSES`] times; the UTXO fails.
898 MaxPasses,
899 /// Past [`DEFERRED_GRAPHS_PER_PEER_TOPIC`] or the storage's ceiling: a
900 /// walk the budget cut goes on under the per-peer budget alone (L4),
901 /// else the UTXO fails. A RESUMED walk's held record is KEPT (bsv-low
902 /// #585): the count is of the pass that found no room, not of a record.
903 TooMany,
904 /// The record could not be written; as [`Self::TooMany`] (D-L2).
905 StoreFault,
906 /// A sync that ran to its end was not served the UTXO (spent at the peer).
907 NotServed,
908 /// The node already holds the UTXO (it landed another way).
909 Held,
910 /// The peer answered it does not hold an input an UNPROVEN node needs;
911 /// the UTXO fails, as in the reference.
912 NotHeld,
913 /// The root came back PROVEN: the walk restarts from it, shorter.
914 RootProven,
915 /// The anchor check refused the graph (a final verdict, the cursor moves).
916 Refused,
917 /// A resumed record whose pass ended still holding no node (a record
918 /// saved before the lens fold's H1, which saves none such); the UTXO
919 /// fails.
920 NoProgress,
921 /// [`DEFERRED_GRAPH_MAX_IDLE_FAULTS`] resumed passes in a row faulted
922 /// having appended no node (the delta-2 fold's D2-M2); the UTXO fails.
923 IdleFaults,
924}
925
926impl DropReason {
927 /// Every reason, for a caller that serves a counter per reason.
928 pub const ALL: [DropReason; 10] = [
929 Self::MaxPasses,
930 Self::TooMany,
931 Self::StoreFault,
932 Self::NotServed,
933 Self::Held,
934 Self::NotHeld,
935 Self::RootProven,
936 Self::Refused,
937 Self::NoProgress,
938 Self::IdleFaults,
939 ];
940
941 /// The reason's name, as logged and counted.
942 pub fn as_str(self) -> &'static str {
943 match self {
944 Self::MaxPasses => "max_passes",
945 Self::TooMany => "too_many",
946 Self::StoreFault => "store_fault",
947 Self::NotServed => "not_served",
948 Self::Held => "held",
949 Self::NotHeld => "not_held",
950 Self::RootProven => "root_proven",
951 Self::Refused => "refused",
952 Self::NoProgress => "no_progress",
953 Self::IdleFaults => "idle_faults",
954 }
955 }
956}
957
958/// One record dropped in a sync.
959#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
960pub struct DroppedGraph {
961 /// The root outpoint.
962 pub outpoint: String,
963 /// Why.
964 pub reason: DropReason,
965}
966
967/// What a sync did with deferred graphs (bsv-low #555).
968#[derive(Debug, Clone, Default, PartialEq)]
969pub struct DeferralStats {
970 /// Per-pass work limits that stopped a root.
971 pub root_bounds: Vec<crate::sync_load::RootBound>,
972 /// Graphs deferred (a new record, or a resumed one deferred again).
973 pub deferred: u64,
974 /// Graphs resumed from a record.
975 pub resumed: u64,
976 /// Resumed graphs that completed and landed (their record deleted).
977 pub converged: u64,
978 /// Partial walks dropped or reset without converging.
979 pub dropped: Vec<DroppedGraph>,
980 /// Walks of the sync that ended unfinished (deferred, or dropped at a
981 /// bound) having appended at least one node in their pass, or that
982 /// completed the graph (the lens fold's H1): the peer answered.
983 pub progressed: u64,
984 /// Walks of the sync that ended unfinished having appended NO node in
985 /// their pass (a hung or failing peer; the lens fold's H1). A sync whose
986 /// only outcome was these is a FAILED attempt for #302's quarantine.
987 pub stalled: u64,
988 /// Records NOT resumed this sync because the resumes of the pass had
989 /// spent their shared budget (the lens fold's M1); held, untouched, for
990 /// the next sync.
991 pub held_back: u64,
992 /// Records sent to the long backoff by this sync (engine 0.1.10): their
993 /// resume appended no node [`IDLE_RESUMES_BEFORE_BACKOFF`] passes in a
994 /// row.
995 pub backed_off: u64,
996 /// Records NOT resumed by this sync because they wait out that backoff
997 /// (no request at all; the cursor passes them).
998 pub waiting: u64,
999 /// Records whose resumed pass this sync counted one idle resume on
1000 /// (engine 0.1.11): it asked and appended nothing it kept, whatever its
1001 /// ending (a save, a drop, an error). A record a strike counted instead
1002 /// is not among them.
1003 pub idle_counted: u64,
1004}
1005
1006impl Walk {
1007 /// The counted limb this pass has spent, if any: its name is the
1008 /// deferral's reason.
1009 fn over(&self) -> Option<&'static str> {
1010 if self.calls_this_pass >= self.cap.calls {
1011 Some("calls")
1012 } else if self.bytes_this_pass >= self.cap.bytes {
1013 Some("bytes")
1014 } else if self.nodes_this_pass >= self.cap.nodes {
1015 Some("nodes")
1016 } else {
1017 None
1018 }
1019 }
1020}
1021
1022/// The walk of the graph in hand: its record (what a deferral saves) and
1023/// the per-pass state. Lives in the [`GASPSync`], OUTSIDE the walk's
1024/// future, so a deadline that drops the future leaves it whole.
1025struct Walk {
1026 record: DeferredGraph,
1027 seen: std::collections::HashSet<String>,
1028 calls_this_pass: u32,
1029 /// Bytes served to this pass (bsv-low #586; see
1030 /// [`DEFAULT_GRAPH_BUDGET_BYTES`] for what is counted).
1031 bytes_this_pass: u64,
1032 /// Nodes this pass appended (bsv-low #586).
1033 nodes_this_pass: u32,
1034 resumed: bool,
1035 /// What this pass may spend: the per-graph budget, or what the resumes
1036 /// of the pass have left of their shared one (M1); [`PassCap::NONE`]
1037 /// once a walk that cannot be kept goes on under the per-peer budget
1038 /// alone (L4).
1039 cap: PassCap,
1040 /// `record.nodes.len()` when the pass started (H1).
1041 nodes_at_start: usize,
1042 /// Whether this pass was already counted progressed or stalled.
1043 counted: bool,
1044 /// The walk could not be kept and GOES ON under the per-peer budget
1045 /// alone (L4): its drop is counted once, when it went on, and it is
1046 /// never saved again in this pass (the delta fold's D-L1: a per-peer
1047 /// deadline that then cut it counted the drop twice and asked the
1048 /// storage a second time).
1049 gone_on: bool,
1050 /// A FRESH walk's half-budget deadline, started with the walk (the
1051 /// delta-2 fold's D2-L1); `None` for a resumed walk or with no
1052 /// [`GraphBudget::half_deadline`].
1053 half: Option<crate::engine::SleepFuture>,
1054 /// The record of a root whose walk faulted in earlier runs (engine
1055 /// 0.1.9), walked again from its root: the walk holds that record's
1056 /// slot, a landing or a verdict deletes it, and a fault counts one more
1057 /// run on it ([`GASPSync::strike`]).
1058 struck: Option<Box<DeferredGraph>>,
1059}
1060
1061/// How a graph's walk ended in this pass.
1062enum WalkEnd {
1063 Expired,
1064 /// Nothing is pending: complete the graph.
1065 Done,
1066 /// Deferred, with this reason.
1067 Deferred(&'static str),
1068}
1069
1070/// What one step of the walk got.
1071enum StepOut {
1072 Expired,
1073 Pruned,
1074 Seen,
1075 Appended {
1076 node_id: String,
1077 walked: Box<WalkedNode>,
1078 children: Vec<PendingInput>,
1079 },
1080}
1081
1082/// How [`GASPSync::ingest_utxo`] ended for a UTXO.
1083enum Ingested {
1084 Retry,
1085 Expired,
1086 Completed,
1087 Deferred,
1088 /// A record not resumed: the pass's resumes spent their shared budget.
1089 HeldBack,
1090 /// An unanchorable root, held (engine 0.1.9): recorded at this walk, or
1091 /// a record whose backoff has not run out. The cursor passes it.
1092 Unanchorable,
1093 /// A held record that waits out the long backoff of its idle resumes
1094 /// (engine 0.1.10): not resumed, no request. The cursor passes it.
1095 Waiting,
1096}
1097
1098/// What a strike did (engine 0.1.10): the record that holds the count
1099/// (its faulted runs, whether it is unanchorable), and why it was refused.
1100#[derive(Debug, Clone, Copy, Default)]
1101struct Strike {
1102 held: Option<(u32, bool)>,
1103 refused: Option<&'static str>,
1104}
1105
1106impl Strike {
1107 /// Whether the root is held unanchorable: the cursor passes it.
1108 fn passable(self) -> bool {
1109 self.held.is_some_and(|(_, unanchorable)| unanchorable)
1110 }
1111}
1112
1113/// What [`GASPSync::save_walk`] did with the walk in hand.
1114enum Saved {
1115 Yes,
1116 Retry,
1117 /// Not kept, for this reason; the walk is handed back so the caller can
1118 /// go on with it (L4) or drop it.
1119 Unkept(DropReason, Box<Walk>),
1120 /// The storage faulted (counted `store_fault`, a held record deleted);
1121 /// the walk is handed back so the caller can go on with it (D-L2).
1122 Fault(GASPError, Box<Walk>),
1123 /// A RESUMED walk the storage had no room for (`AtCeiling`; counted
1124 /// `too_many`): the record it holds is KEPT as it was (bsv-low #585), and
1125 /// the walk is handed back so the caller can go on with it.
1126 Ceiling(Box<Walk>),
1127 /// The walk had already gone on (D-L1): nothing saved, nothing counted.
1128 WentOn,
1129}
1130
1131// ============================================================================
1132// GASPStorage trait
1133// ============================================================================
1134
1135/// Local storage interface for the GASP protocol.
1136///
1137/// Manages known UTXOs, temporary graph construction during sync,
1138/// and graph validation/finalization.
1139#[async_trait(?Send)]
1140pub trait GASPStorage {
1141 /// The root advert's own expiry. The adapter binds the bytes to the
1142 /// requested outpoint before interpreting a topic's expiry field.
1143 fn sync_expiry(&self, _node: &GASPNode, _outpoint: &str) -> Result<Option<u64>, GASPError> {
1144 Ok(None)
1145 }
1146
1147 /// Returns UTXOs known to be unspent since the given score/timestamp.
1148 /// Non-confirmed UTXOs should always be returned regardless of `since`.
1149 async fn find_known_utxos(
1150 &self,
1151 since: u64,
1152 limit: Option<u64>,
1153 ) -> Result<Vec<GASPOutput>, GASPError>;
1154
1155 /// Hydrate a GASP node with transaction data, proof, and optional metadata.
1156 async fn hydrate_gasp_node(
1157 &self,
1158 graph_id: &str,
1159 txid: &str,
1160 output_index: u32,
1161 metadata: bool,
1162 ) -> Result<GASPNode, GASPError>;
1163
1164 /// Determine which input transactions are needed to validate this node.
1165 /// Returns None if no additional inputs are needed.
1166 async fn find_needed_inputs(
1167 &self,
1168 node: &GASPNode,
1169 ) -> Result<Option<GASPNodeResponse>, GASPError>;
1170
1171 /// Append a node to a temporary graph being constructed during sync.
1172 /// `spent_by` is the "txid.outputIndex" of the node that spent this one (if not the root).
1173 async fn append_to_graph(
1174 &self,
1175 node: &GASPNode,
1176 spent_by: Option<&str>,
1177 ) -> Result<(), GASPError>;
1178
1179 /// Validate that the graph's anchor (root) references only proven or known transactions.
1180 async fn validate_graph_anchor(&self, graph_id: &str) -> Result<(), GASPError>;
1181
1182 /// Finalize a graph — commit the synced UTXO and its ancestors to permanent storage.
1183 async fn finalize_graph(&self, graph_id: &str) -> Result<(), GASPError>;
1184
1185 /// Discard a temporary graph that failed validation.
1186 async fn discard_graph(&self, graph_id: &str) -> Result<(), GASPError>;
1187
1188 /// The keys of the deferred graphs of this (peer, topic), lowest score
1189 /// first (bsv-low #555). REQUIRED, as the three below (the lens fold's
1190 /// M2): a storage that keeps no records says so here, by answering none
1191 /// and refusing every save, and its graphs past the budget then fail.
1192 async fn load_deferred_graphs(&self) -> Result<Vec<DeferredGraphKey>, GASPError>;
1193
1194 /// The record of the graph rooted at `outpoint`, if one is held.
1195 async fn get_deferred_graph(&self, outpoint: &str) -> Result<Option<DeferredGraph>, GASPError>;
1196
1197 /// Save (replace) the record of one deferred graph.
1198 async fn save_deferred_graph(
1199 &self,
1200 record: &DeferredGraph,
1201 ) -> Result<DeferredGraphSave, GASPError>;
1202
1203 /// Batched compact facts for a page, or due facts and maintenance with None.
1204 /// Standalone adapters may have no memo; engine adapters must forward it.
1205 async fn read_fault_memos(
1206 &self,
1207 _outpoints: Option<&[String]>,
1208 ) -> Result<crate::fault_memo::FaultMemoBatch, GASPError> {
1209 Ok(crate::fault_memo::FaultMemoBatch::default())
1210 }
1211
1212 /// Atomically claim the next retry. A storage without retry support declines.
1213 async fn reserve_unanchorable_retry(&self, _record: &DeferredGraph) -> Result<bool, GASPError> {
1214 Ok(false)
1215 }
1216
1217 /// Delete the record of the graph rooted at `outpoint`.
1218 async fn delete_deferred_graph(&self, outpoint: &str) -> Result<(), GASPError>;
1219
1220 /// Remember a root refused as expired at `expiry` (engine 0.1.8), so a
1221 /// later sync skips it with no node fetch. Default: nothing kept, the
1222 /// walk fetches and refuses it again (fail-safe).
1223 async fn remember_expired(&self, _outpoint: &str, _expiry: u64) -> Result<(), GASPError> {
1224 Ok(())
1225 }
1226
1227 /// Of `outpoints`, those remembered as expired. Default: none.
1228 async fn remembered_expired(&self, _outpoints: &[String]) -> Result<Vec<String>, GASPError> {
1229 Ok(Vec::new())
1230 }
1231}
1232
1233// ============================================================================
1234// GASPRemote trait
1235// ============================================================================
1236
1237/// Communication interface with a foreign GASP peer.
1238#[async_trait(?Send)]
1239pub trait GASPRemote {
1240 /// Send an initial request and get the peer's initial response.
1241 async fn get_initial_response(
1242 &self,
1243 request: &GASPInitialRequest,
1244 ) -> Result<GASPInitialResponse, GASPError>;
1245
1246 /// Send our initial response and get the peer's reply.
1247 async fn get_initial_reply(
1248 &self,
1249 response: &GASPInitialResponse,
1250 ) -> Result<GASPInitialReply, GASPError>;
1251
1252 /// Request a specific node from the peer.
1253 async fn request_node(
1254 &self,
1255 graph_id: &str,
1256 txid: &str,
1257 output_index: u32,
1258 metadata: bool,
1259 ) -> Result<GASPNode, GASPError>;
1260
1261 /// Submit a node to the peer and get back which inputs they need.
1262 async fn submit_node(&self, node: &GASPNode) -> Result<Option<GASPNodeResponse>, GASPError>;
1263
1264 /// Whether this remote can ask the peer for many nodes in one body
1265 /// (engine 0.2.1, #58; [`crate::node_batch`]). `false` for a remote that
1266 /// has no batch request: the sync then asks one node at a time.
1267 fn batches(&self) -> bool {
1268 false
1269 }
1270
1271 /// Ask the peer for the nodes of `outpoints` in one body
1272 /// (`POST /requestForeignGASPNodes`). Any error, a 404 from a peer that
1273 /// does not answer the route included, makes the sync ask that peer one
1274 /// node at a time for the rest of the run.
1275 async fn request_nodes(
1276 &self,
1277 _outpoints: &[crate::node_batch::BatchOutpoint],
1278 ) -> Result<crate::node_batch::NodeBatch, GASPError> {
1279 Err(GASPError::Other(
1280 "this remote has no batch node request".into(),
1281 ))
1282 }
1283}
1284
1285// ============================================================================
1286// GASPRemoteFactory trait
1287// ============================================================================
1288
1289/// Factory for creating `GASPRemote` instances for specific peers.
1290///
1291/// The Engine holds an optional factory. Platform-specific crates (like
1292/// overlay-cloudflare) provide an implementation that creates HTTP-based
1293/// remotes.
1294pub trait GASPRemoteFactory {
1295 /// Create a `GASPRemote` for the given peer URL and topic.
1296 fn create_remote(&self, peer_url: &str, topic: &str) -> Box<dyn GASPRemote>;
1297}
1298
1299// ============================================================================
1300// AncestorFetcher trait (OPT-IN, OFF by default)
1301// ============================================================================
1302
1303/// Optional chain-backed ancestor fetcher for GASP ingest self-healing.
1304///
1305/// **OPT-IN / OFF BY DEFAULT.** When a peer (e.g. legacy TS beta) cannot serve
1306/// a needed ancestor node during graph ingest (it returns HTTP 400 "Incomplete
1307/// SPV data!" because its stored BEEF is minimal), the orchestrator normally
1308/// abandons that graph. If — and ONLY if — an `AncestorFetcher` is configured,
1309/// `process_incoming_node` falls back to fetching the ancestor's raw tx from
1310/// chain (e.g. WhatsOnChain) and synthesizing a no-proof `GASPNode` that the
1311/// existing recursion stitches into the graph.
1312///
1313/// This is a deliberate one-time-migration escape hatch. Production must NOT
1314/// configure a fetcher: when the fetcher is `None` the ingest path is
1315/// byte-identical to today (peer errors propagate / are swallowed upstream).
1316///
1317/// The orchestrator stays platform-agnostic; the concrete WoC/`worker::Fetch`
1318/// implementation lives in the platform crate (e.g. zanaadu `overlay`), never
1319/// in this engine crate (no `reqwest`/`std::time` — must stay wasm-clean).
1320#[async_trait(?Send)]
1321pub trait AncestorFetcher {
1322 /// Reset invocation-local retry caches before a new sync.
1323 fn begin_sync(&self) {}
1324
1325 /// Fetch an ancestor transaction by txid from chain.
1326 ///
1327 /// Returns the raw tx hex plus, when the ancestor is mined, its BUMP merkle
1328 /// proof hex. A proven node ends the walk unless its topic manager names
1329 /// inputs needed for overlay history. Managers naming nothing still stop
1330 /// at the first proven layer. Managers needing history select those inputs
1331 /// instead of walking every funding/fee input back toward coinbase.
1332 ///
1333 /// The transaction DAG (no cycles), `seen` keyed by `txid.outputIndex`,
1334 /// the already-known strip, and the manager naming nothing at genesis
1335 /// bound the walk. There is no node cap, matching the reference's undefined
1336 /// `maxNodesInGraph` in `Engine.startGASPSync`. A per-peer budget drops the
1337 /// sync future at its deadline: the graphs finalized before it stay
1338 /// admitted (bsv-low #552, [`FinalizedGraphHook`]), the graph in flight
1339 /// is lost whole and walked again by the next sync, unless a per-graph
1340 /// budget is set (bsv-low #555, `Engine::set_graph_budget`): then a walk
1341 /// past either budget is deferred with its fetched nodes kept and resumed
1342 /// by the next sync, so ONE graph deeper than a pass converges over
1343 /// passes. Without it such a graph still never completes.
1344 ///
1345 /// The implementation MUST verify that the returned bytes hash to the
1346 /// requested `txid` before returning them (integrity check) so a
1347 /// malicious/garbled response cannot inject a forged ancestor.
1348 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError>;
1349
1350 /// Fetch (and chaintracks-verify) ONLY the merkle BUMP for `txid`, WITHOUT
1351 /// fetching the raw tx.
1352 ///
1353 /// The proof-completion passes (`complete_missing_proofs`, the LOW pot-store
1354 /// tick) already hold the raw in the stored BEEF, so the raw fetch that
1355 /// [`Self::fetch_ancestor`] performs is a redundant network round-trip there
1356 /// — and a free-tier WhatsOnChain raw fetch 429s (#192/#193). This method is
1357 /// the raw-free path.
1358 ///
1359 /// Default: delegate to `fetch_ancestor` and drop the raw, so a fetcher that
1360 /// only implements `fetch_ancestor` keeps working unchanged. The production
1361 /// `ChainProofFetcher` overrides it to skip the raw fetch entirely. Returns
1362 /// `None` for an unmined/unverifiable tx (fail-closed), never an error.
1363 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
1364 self.fetch_ancestor(txid).await.ok().and_then(|a| a.proof)
1365 }
1366
1367 /// Like [`Self::verified_proof_for`], but a TRANSPORT/READ FAULT stays
1368 /// distinguishable (bsv-low#304 gate M-5): `Err` = the proof source or
1369 /// header source READ failed (e.g. a chaintracks call starved at the
1370 /// invocation's subrequest wall) — retryable, NOT a chain verdict;
1371 /// `Ok(None)` = honestly no verified proof yet. Money-relevant callers
1372 /// (the spend-confirmation chaser) surface + count the fault so a
1373 /// starved tick is visible instead of masquerading as "not mined yet".
1374 /// Default: wrap `verified_proof_for` (which never faults) — existing
1375 /// fetchers keep their semantics unchanged.
1376 async fn verified_proof_for_detailed(&self, txid: &str) -> Result<Option<String>, String> {
1377 Ok(self.verified_proof_for(txid).await)
1378 }
1379
1380 /// bsv-low #451 slice B (2026-09-17): like [`Self::verified_proof_for_detailed`], but the ladder asks EVERY
1381 /// rung — no per-pass short-circuit on the broadcaster's "held unmined" word. The completion passes may stop
1382 /// early (a fresh unmined word from Arcade means no courier holds a proof yet; the next tick asks again), but
1383 /// a caller that ACTS on `Ok(None)` this pass — the reorg re-anchor, which demotes a refuted row it could not
1384 /// re-prove; the unmined-ancestry walk, which treats an unproven ancestor as a branch to keep walking — must
1385 /// hear the couriers too (the delta-verify's LOW-A). Default: the detailed ask (a fetcher with no short-circuit
1386 /// has nothing to skip).
1387 async fn verified_proof_for_exhaustive(&self, txid: &str) -> Result<Option<String>, String> {
1388 self.verified_proof_for_detailed(txid).await
1389 }
1390
1391 /// bsv-low M19B-G1 round 2 (review MED-2): the ladder's REMAINING
1392 /// per-invocation budget, when it keeps one (`None` = unbounded, or not
1393 /// known). A budgeted ladder answers `Ok(None)` to every ask once its
1394 /// budget is spent, which a caller must not read as "no courier serves a
1395 /// proof": it checks this first and leaves the row for a fresh budget.
1396 fn budget_remaining(&self) -> Option<u32> {
1397 None
1398 }
1399
1400 /// Verify that `bump_hex` is a chaintracks-valid merkle proof for `txid`.
1401 ///
1402 /// Used by proof completion to re-check a STORED structural bump before
1403 /// trusting its `has_proof` flag: a structural bump admitted WITHOUT SPV (or
1404 /// forged) must never be latched-proven and trimmed on (#192/#193). Default:
1405 /// fail-closed `false` — a fetcher with no header source can prove nothing.
1406 /// The production `ChainProofFetcher` overrides it against chaintracks.
1407 async fn verify_proof(&self, txid: &str, bump_hex: &str) -> bool {
1408 let _ = (txid, bump_hex);
1409 false
1410 }
1411
1412 /// Which txid spends `txid:vout`, per an EXTERNAL INDEXER — a HINT, never
1413 /// a verdict (bsv-low 2026-08-18, the displaced-spender reconcile). The
1414 /// caller must independently prove the hint before acting on it:
1415 /// [`Self::spender_binding_raw`] (the bytes really spend the outpoint)
1416 /// and [`Self::verified_proof_for_detailed`] (the spender is MINED, root
1417 /// verified against our PoW-anchored headers). An indexer can therefore
1418 /// only ever point at the REAL confirmed spender — pointing anywhere else
1419 /// fails one of the two proofs and changes nothing.
1420 ///
1421 /// Default `Ok(None)` — unknown — so every existing implementation keeps
1422 /// compiling AND keeps today's behavior (no resolution, fail-closed).
1423 async fn resolve_spender(&self, _txid: &str, _vout: u32) -> Result<Option<String>, String> {
1424 Ok(None)
1425 }
1426
1427 /// bsv-low (2026-09-04) — the SCRIPTHASH-HISTORY rung: the txids a
1428 /// courier's script-history index lists for `scripthash_le_hex` OTHER
1429 /// than `funding_txid` (newest first, a few at most). A pot's covenant
1430 /// script is unique to that pot, so its history is exactly
1431 /// `[funding, spender]` and the answer is the spender CANDIDATE; the
1432 /// caller BINDS it with [`spender_binding_raw`] before believing it. This
1433 /// rung survives a provider retiring its per-output spend endpoint
1434 /// (Bitails, pruned mode, 2026-09). `Err` = every rung faulted; an empty
1435 /// `Ok` = the indexes answered and list no other tx. Default: no index.
1436 ///
1437 /// [`spender_binding_raw`]: AncestorFetcher::spender_binding_raw
1438 async fn resolve_spender_by_script(
1439 &self,
1440 _scripthash_le_hex: &str,
1441 _funding_txid: &str,
1442 ) -> Result<Vec<String>, String> {
1443 Ok(Vec::new())
1444 }
1445
1446 /// bsv-low (2026-09-04): an output's LOCKING SCRIPT by outpoint from a
1447 /// courier's output index (BananaBlocks `/txo/{txid}/{vout}`) — the
1448 /// scripthash rung's script source when no BEEF is stored for the row.
1449 /// `Ok(None)` = the index never saw it. Default: no such index.
1450 async fn output_script_hint(&self, _txid: &str, _vout: u32) -> Result<Option<Vec<u8>>, String> {
1451 Ok(None)
1452 }
1453
1454 /// `spender`'s raw tx hex — fetched CONTENT-ADDRESSED (the bytes must
1455 /// hash to `spender`) — returned iff those bytes carry an input spending
1456 /// exactly `txid:vout`. This is the binding that turns
1457 /// [`Self::resolve_spender`]'s hint into a fact, and the caller gets the
1458 /// PROVEN bytes back so it can derive further facts from them (bytes
1459 /// finality, a durable BEEF) without a second fetch. `Ok(None)` = the
1460 /// bytes were read and do NOT bind (a lying or garbled hint — refuse);
1461 /// `Err` = could not read (a fault, never a verdict). Default `Ok(None)`:
1462 /// fail-closed, an unbindable hint is a dead hint.
1463 async fn spender_binding_raw(
1464 &self,
1465 _spender: &str,
1466 _txid: &str,
1467 _vout: u32,
1468 ) -> Result<Option<String>, String> {
1469 Ok(None)
1470 }
1471}
1472
1473/// An ancestor transaction fetched from chain: its raw tx hex plus an optional
1474/// BUMP merkle proof hex (present when the tx is mined).
1475#[derive(Debug, Clone)]
1476pub struct FetchedAncestor {
1477 /// Raw transaction hex.
1478 pub raw_tx: String,
1479 /// BUMP merkle proof hex, if the ancestor is mined. When `Some`, the
1480 /// synthesized node ends the walk unless its topic manager names inputs.
1481 pub proof: Option<String>,
1482}
1483
1484// ============================================================================
1485// FinalizedGraphHook (bsv-low #552)
1486// ============================================================================
1487
1488/// Called by [`GASPSync::sync`] after EVERY incoming UTXO whose graph was
1489/// completed (finalized, or refused by the anchor check and discarded), before
1490/// the next UTXO is asked for.
1491///
1492/// The reference submits inside `finalizeGraph`, so a graph is admitted the
1493/// moment it is finalized. Here `OverlayGASPStorage::finalize_graph` only
1494/// pushes to a sink (it holds a `Storage`, not the engine), and the engine
1495/// used to drain that sink after the WHOLE sync: a sync dropped at its
1496/// per-peer budget admitted nothing. With a hook the engine drains the sink
1497/// per graph, inside the raced future, so what was finalized before the
1498/// deadline stays admitted and the next sync's known-UTXO skip and known-input
1499/// strip resume from it.
1500///
1501/// An `Err` FAILS THAT UTXO, exactly as a failed ingest does: the gap guard
1502/// keeps the cursor below it and the next sync asks for it again. The engine
1503/// answers `Err` when a finalize submit did not land (the lens fold's
1504/// MEDIUM-1). It cannot fail the sync. While the hook runs,
1505/// [`GASPSync::completed_cursor`] still sits BELOW the UTXO just completed,
1506/// so a sync dropped inside the hook asks for that UTXO again.
1507#[async_trait(?Send)]
1508pub trait FinalizedGraphHook {
1509 /// One incoming UTXO's graph was completed; drain what it finalized.
1510 async fn graph_completed(&self) -> Result<(), GASPError>;
1511}
1512
1513// ============================================================================
1514// GASP orchestrator
1515// ============================================================================
1516
1517#[derive(Default)]
1518struct PageProgress {
1519 pages: u64,
1520 advanced: bool,
1521 reached_tip: bool,
1522}
1523
1524/// The node batch's state in one sync (engine 0.2.1, #58).
1525#[derive(Default)]
1526struct BatchState {
1527 /// The peer asked one node at a time for the rest of the run: its batch
1528 /// request failed or its answer was refused. Shared across the run's
1529 /// topics and slices, never persisted.
1530 single_only: std::rc::Rc<std::cell::Cell<bool>>,
1531 /// The page's outputs a batch may ask for, in page order.
1532 queue: std::cell::RefCell<std::collections::VecDeque<String>>,
1533 /// The batch answers not yet walked, each as its single request would
1534 /// have answered.
1535 prefetched: std::cell::RefCell<std::collections::HashMap<String, Result<GASPNode, GASPError>>>,
1536}
1537
1538/// GASP sync orchestrator.
1539///
1540/// Coordinates between local `GASPStorage` and a `GASPRemote` peer to
1541/// synchronize overlay UTXOs. Supports paginated sync and unidirectional mode.
1542///
1543/// The lifetime `'a` allows the storage and remote to borrow from their
1544/// environment (e.g., `OverlayGASPStorage` borrows from the Engine's storage).
1545pub struct GASPSync<'a> {
1546 expired: std::rc::Rc<std::cell::RefCell<std::collections::HashSet<String>>>,
1547 /// The node batch's state (engine 0.2.1, #58), boxed so the sync's
1548 /// future does not grow by it.
1549 batch: Box<BatchState>,
1550 root_proofs: std::cell::RefCell<std::collections::HashMap<String, bool>>,
1551 roots_proven: std::cell::Cell<u64>,
1552 roots_unproven: std::cell::Cell<u64>,
1553 cursor_held_by: Option<(u64, String)>,
1554 cursor_after_in_flight: u64,
1555 /// The root (score, outpoint) of the graph in flight, kept OUTSIDE the
1556 /// walk's future like [`Walk`], so a per-peer deadline that drops the
1557 /// sync before the walk exists (or after a refusal took it) still names
1558 /// the graph it cut.
1559 in_flight: Option<(u64, String)>,
1560 /// New durable holds this sync saved: see [`Self::graphs_retained`].
1561 retained: std::cell::Cell<u64>,
1562 /// Held graphs this sync resumed that appended a node or converged.
1563 resumed_with_progress: std::cell::Cell<u64>,
1564 cursor_yielded: std::cell::Cell<bool>,
1565 expired_refused: std::cell::Cell<u64>,
1566 /// The expiry `root_expired` last read off a refused root, for the
1567 /// durable memo.
1568 refused_expiry: std::cell::Cell<Option<u64>>,
1569 storage: Box<dyn GASPStorage + 'a>,
1570 remote: Box<dyn GASPRemote + 'a>,
1571 /// Score of last successful interaction with this peer.
1572 pub last_interaction: u64,
1573 /// If true, only pull from remote — don't push local UTXOs.
1574 pub unidirectional: bool,
1575 log_prefix: String,
1576 /// OPT-IN / OFF BY DEFAULT chain-backed ancestor fetcher. When `None`
1577 /// (the default), a peer's `request_node` error propagates and the graph
1578 /// is abandoned upstream, except for an input NAMED by a proven node's
1579 /// topic manager that the peer answers it does not hold
1580 /// (`GASPError::NodeNotFound`), whose branch is pruned (the D8 decoy
1581 /// rule). When `Some`, ancestors come from chain instead of the peer and
1582 /// nothing is pruned: every fetcher error abandons the graph.
1583 ancestor_fetcher: Option<std::rc::Rc<dyn AncestorFetcher + 'a>>,
1584 /// Manager-named inputs whose branch was PRUNED because the peer answered
1585 /// that it does not hold them (zanaadu-v2 #314, D8), keyed
1586 /// `txid.outputIndex`. One entry is one failed round trip: an outpoint in
1587 /// this set is never asked for again in this sync, whichever parent or
1588 /// graph names it. Cleared at the start of each `sync`.
1589 pruned: std::cell::RefCell<std::collections::HashSet<String>>,
1590 /// Distinct outpoints pruned in the last `sync` (the size of `pruned`).
1591 pruned_inputs: std::cell::Cell<u64>,
1592 /// Inputs of UNPROVEN nodes the peer answered it does not hold in this
1593 /// sync (engine 0.1.15), keyed `txid.outputIndex`: needed again by
1594 /// another graph of the sync, the answer is remembered, not asked.
1595 /// Cleared at the start of each `sync`.
1596 absent: std::cell::RefCell<std::collections::HashSet<String>>,
1597 /// Graphs REFUSED by `validate_graph_anchor` and discarded in the last
1598 /// `sync` (bsv-low #551). Cleared at the start of each `sync`.
1599 discarded_graphs: std::cell::Cell<u64>,
1600 /// Graphs whose anchor walk could not run within the budget and were
1601 /// HELD (the E592 lens fold): [`GASPSync::anchor_walk_held`].
1602 anchor_walk_held: std::cell::Cell<u64>,
1603 /// Called after every completed incoming UTXO (bsv-low #552). `None`
1604 /// (the default): nothing is called and `sync` runs as it always did.
1605 finalized_hook: Option<Box<dyn FinalizedGraphHook + 'a>>,
1606 /// See [`Self::completed_cursor`].
1607 completed_cursor: u64,
1608 /// See [`Self::graph_in_flight`].
1609 graph_in_flight: bool,
1610 /// The per-graph budget (bsv-low #555). `None` (the default): no graph
1611 /// is deferred and the walk is the one before #555.
1612 graph_budget: Option<GraphBudget<'a>>,
1613 /// The roots of this (peer, topic)'s records not yet resumed in this
1614 /// sync (keys only; records are read one at a time).
1615 deferred: std::cell::RefCell<std::collections::HashSet<String>>,
1616 /// ONE deadline for every resume of the sync (the lens fold's M1), made
1617 /// at the first resume.
1618 resume_deadline: std::cell::RefCell<Option<crate::engine::SleepFuture>>,
1619 /// What every resume of the sync may still spend together (M1).
1620 resume_left: std::cell::Cell<PassCap>,
1621 /// A fresh deadline for the resumes' share of a pass (engine 0.1.11,
1622 /// [`Self::with_resume_share`]); `None`: the per-graph deadline, made at
1623 /// the resume that opens the pass, as before.
1624 resume_share: Option<Box<dyn Fn() -> crate::engine::SleepFuture + 'a>>,
1625 /// Calls made by the sync's walks so far.
1626 calls_made: std::cell::Cell<u64>,
1627 /// Bytes served to the sync's walks so far (bsv-low #586).
1628 bytes_fetched: std::cell::Cell<u64>,
1629 /// Nodes appended by the sync's walks so far (bsv-low #586).
1630 nodes_appended: std::cell::Cell<u64>,
1631 /// How many records this (peer, topic) holds in storage, its fault
1632 /// records apart (engine 0.1.10: they are counted in `fault_records`).
1633 held_records: std::cell::Cell<usize>,
1634 /// The roots of this (peer, topic)'s FAULT records known in this sync
1635 /// (engine 0.1.14), outside the partial-graph slot count: each
1636 /// held one is known when the sync reads it (every held record is read
1637 /// ahead of the pages), a new one when its strike is written.
1638 fault_records: std::cell::RefCell<std::collections::HashSet<String>>,
1639 /// See [`Self::strikes_refused`].
1640 strikes_refused: std::cell::Cell<u64>,
1641 /// The idle resumes counted on the held record the resume in hand read
1642 /// (engine 0.1.10), and the nodes it held (engine 0.1.11), before its
1643 /// pass: what its pass adds one to when it asks and keeps nothing more.
1644 /// `None` for any other walk.
1645 resume_idle: std::cell::Cell<Option<(u32, usize)>>,
1646 /// The pass in hand saved its own record ([`Self::save_walk`]): that save
1647 /// counted its idle resume, or reset the count on a node appended, so
1648 /// nothing is counted after it.
1649 pass_saved: std::cell::Cell<bool>,
1650 /// The walk of the graph in hand (see [`Walk`]).
1651 walk: std::cell::RefCell<Option<Walk>>,
1652 /// See [`Self::deferral_stats`].
1653 stats: std::cell::RefCell<DeferralStats>,
1654 /// See [`Self::graphs_attempted`].
1655 graphs_attempted: std::cell::Cell<u64>,
1656 /// The fault class of the step that last failed a walk (engine 0.1.9),
1657 /// set where the walk faults and read where its root is dropped;
1658 /// `None` for a failure that is no fault of the graph (a storage fault
1659 /// of ours, a bound).
1660 last_fault: std::cell::Cell<Option<&'static str>>,
1661 /// See [`Self::faulted_roots`].
1662 faulted: std::cell::RefCell<Vec<FaultedRoot>>,
1663 /// See [`Self::unanchorable_recorded`].
1664 unanchorable_recorded: std::cell::Cell<u64>,
1665 /// See [`Self::unanchorable_cleared`].
1666 unanchorable_cleared: std::cell::Cell<u64>,
1667 /// See [`Self::absent_struck`].
1668 absent_struck: std::cell::Cell<u64>,
1669 dead_recorded: std::cell::Cell<u64>,
1670 expired_swept: std::cell::Cell<u64>,
1671 memo_folded: std::cell::Cell<u64>,
1672 root_expiries: std::cell::RefCell<std::collections::HashMap<String, u64>>,
1673 page: PageProgress,
1674 request_stop: Option<std::rc::Rc<dyn Fn() -> bool + 'a>>,
1675}
1676
1677impl<'a> GASPSync<'a> {
1678 /// Create a new GASP sync orchestrator.
1679 ///
1680 /// The ancestor fetcher is OFF by default. Use `with_ancestor_fetcher` to
1681 /// opt in to chain-backed ancestry hydration (one-time-migration only).
1682 pub fn new(
1683 storage: Box<dyn GASPStorage + 'a>,
1684 remote: Box<dyn GASPRemote + 'a>,
1685 last_interaction: u64,
1686 log_prefix: impl Into<String>,
1687 unidirectional: bool,
1688 ) -> Self {
1689 Self {
1690 storage,
1691 remote,
1692 expired: std::rc::Rc::default(),
1693 batch: Box::default(),
1694 expired_refused: std::cell::Cell::new(0),
1695 refused_expiry: std::cell::Cell::new(None),
1696 root_proofs: std::cell::RefCell::default(),
1697 roots_proven: std::cell::Cell::new(0),
1698 roots_unproven: std::cell::Cell::new(0),
1699 cursor_held_by: None,
1700 cursor_after_in_flight: last_interaction,
1701 in_flight: None,
1702 retained: std::cell::Cell::new(0),
1703 resumed_with_progress: std::cell::Cell::new(0),
1704 cursor_yielded: std::cell::Cell::new(false),
1705 last_interaction,
1706 unidirectional,
1707 log_prefix: log_prefix.into(),
1708 ancestor_fetcher: None,
1709 pruned: std::cell::RefCell::new(std::collections::HashSet::new()),
1710 pruned_inputs: std::cell::Cell::new(0),
1711 absent: std::cell::RefCell::default(),
1712 discarded_graphs: std::cell::Cell::new(0),
1713 anchor_walk_held: std::cell::Cell::new(0),
1714 finalized_hook: None,
1715 completed_cursor: last_interaction,
1716 graph_in_flight: false,
1717 graph_budget: None,
1718 deferred: std::cell::RefCell::new(std::collections::HashSet::new()),
1719 resume_deadline: std::cell::RefCell::new(None),
1720 resume_left: std::cell::Cell::new(PassCap::NONE),
1721 resume_share: None,
1722 calls_made: std::cell::Cell::new(0),
1723 bytes_fetched: std::cell::Cell::new(0),
1724 nodes_appended: std::cell::Cell::new(0),
1725 held_records: std::cell::Cell::new(0),
1726 fault_records: std::cell::RefCell::default(),
1727 strikes_refused: std::cell::Cell::new(0),
1728 resume_idle: std::cell::Cell::new(None),
1729 pass_saved: std::cell::Cell::new(false),
1730 walk: std::cell::RefCell::new(None),
1731 stats: std::cell::RefCell::new(DeferralStats::default()),
1732 graphs_attempted: std::cell::Cell::new(0),
1733 last_fault: std::cell::Cell::new(None),
1734 faulted: std::cell::RefCell::default(),
1735 unanchorable_recorded: std::cell::Cell::new(0),
1736 unanchorable_cleared: std::cell::Cell::new(0),
1737 absent_struck: std::cell::Cell::new(0),
1738 dead_recorded: std::cell::Cell::new(0),
1739 expired_swept: std::cell::Cell::new(0),
1740 memo_folded: std::cell::Cell::new(0),
1741 root_expiries: std::cell::RefCell::default(),
1742 page: PageProgress::default(),
1743 request_stop: None,
1744 }
1745 }
1746
1747 /// Share expired outpoints across the topic peers of one engine run.
1748 #[must_use]
1749 pub fn with_expired_outpoints(
1750 mut self,
1751 expired: std::rc::Rc<std::cell::RefCell<std::collections::HashSet<String>>>,
1752 ) -> Self {
1753 self.expired = expired;
1754 self
1755 }
1756
1757 /// Share the run's single-only memo of this peer (engine 0.2.1, #58).
1758 #[must_use]
1759 pub fn with_batch_memo(mut self, single_only: std::rc::Rc<std::cell::Cell<bool>>) -> Self {
1760 self.batch.single_only = single_only;
1761 self
1762 }
1763
1764 /// Backfill slices stop after one page or at a caller-owned request bound.
1765 pub fn with_page_slice(mut self, stop: std::rc::Rc<dyn Fn() -> bool + 'a>) -> Self {
1766 self.request_stop = Some(stop);
1767 self
1768 }
1769 fn request_stopped(&self) -> bool {
1770 self.request_stop.as_ref().is_some_and(|stop| stop())
1771 }
1772 pub fn pages(&self) -> u64 {
1773 self.page.pages
1774 }
1775 pub fn page_advanced(&self) -> bool {
1776 self.page.advanced
1777 }
1778 pub fn reached_tip(&self) -> bool {
1779 self.page.reached_tip
1780 }
1781
1782 pub fn expired_refused(&self) -> u64 {
1783 self.expired_refused.get()
1784 }
1785
1786 pub fn roots_proven(&self) -> u64 {
1787 self.roots_proven.get()
1788 }
1789 pub fn roots_unproven(&self) -> u64 {
1790 self.roots_unproven.get()
1791 }
1792 pub fn cursor_held_by(&self) -> Option<String> {
1793 self.cursor_held_by
1794 .as_ref()
1795 .map(|(_, outpoint)| outpoint.clone())
1796 }
1797
1798 pub fn cursor_yielded(&self) -> bool {
1799 self.cursor_yielded.get()
1800 }
1801
1802 /// New durable holds the last `sync` saved holding at least one node
1803 /// the peer served (a walk past its budget, or the graph in flight at
1804 /// the per-peer deadline): work the cursor may pass because the next
1805 /// sync resumes it. A hold of nothing is not counted: the peer served
1806 /// no node of it (the hung peer of bsv-low #555's H1).
1807 pub fn graphs_retained(&self) -> u64 {
1808 self.retained.get()
1809 }
1810
1811 /// Whether the last `sync` did work beyond finalizing a graph or moving
1812 /// the cursor (engine 0.1.8): it refused an expired root not refused
1813 /// before, retained a graph or resumed one with progress. Such a sync is
1814 /// never a failed attempt. Only the refusal also ends the yieldless
1815 /// streak (see `Engine::start_gasp_sync`).
1816 pub fn did_work(&self) -> bool {
1817 self.expired_refused.get() > 0
1818 || self.retained.get() > 0
1819 || self.resumed_with_progress.get() > 0
1820 }
1821
1822 /// Held graphs the last `sync` resumed that appended at least one node
1823 /// or converged.
1824 pub fn graphs_resumed_with_progress(&self) -> u64 {
1825 self.resumed_with_progress.get()
1826 }
1827
1828 /// The roots the last `sync` dropped, each named once with its fault
1829 /// class (engine 0.1.9): what `cursor_held_by` cannot say.
1830 pub fn faulted_roots(&self) -> Vec<FaultedRoot> {
1831 self.faulted.borrow().clone()
1832 }
1833
1834 /// Roots the last `sync` recorded unanchorable: their walk faulted in
1835 /// [`UNANCHORABLE_AFTER_FAULTED_RUNS`] consecutive runs, and the cursor
1836 /// passes them from now on.
1837 pub fn unanchorable_recorded(&self) -> u64 {
1838 self.unanchorable_recorded.get()
1839 }
1840
1841 /// Roots the last `sync` struck `unanchorable:not_found` (engine 0.1.15):
1842 /// the peers' word of absence counted on [`UNANCHORABLE_AFTER_FAULTED_RUNS`]
1843 /// runs at least [`ABSENCE_GAP_SECS`] apart. Counted in
1844 /// [`Self::unanchorable_recorded`] too.
1845 pub fn absent_struck(&self) -> u64 {
1846 self.absent_struck.get()
1847 }
1848
1849 /// Roots with a fault record (unanchorable or not yet) that the last
1850 /// `sync` walked again and admitted or refused on a verdict: their
1851 /// record cleared.
1852 pub fn dead_recorded(&self) -> u64 {
1853 self.dead_recorded.get()
1854 }
1855
1856 pub fn memo_folded(&self) -> u64 {
1857 self.memo_folded.get()
1858 }
1859
1860 pub fn expired_swept(&self) -> u64 {
1861 self.expired_swept.get()
1862 }
1863
1864 pub fn unanchorable_cleared(&self) -> u64 {
1865 self.unanchorable_cleared.get()
1866 }
1867
1868 /// Strikes the last `sync` could not write at the storage's ceiling
1869 /// or on a storage fault. Each is named in
1870 /// [`Self::faulted_roots`] with its [`FaultedRoot::refused`]; a refused
1871 /// strike of a new root leaves it a gap the cursor holds below.
1872 pub fn strikes_refused(&self) -> u64 {
1873 self.strikes_refused.get()
1874 }
1875
1876 /// Know `outpoint` as a fault record of this (peer, topic) (engine
1877 /// 0.1.10): one counted among the deferred slots moves to the fault
1878 /// records. A no-op for one already known.
1879 fn know_fault_record(&self, outpoint: &str) {
1880 if self.fault_records.borrow_mut().insert(outpoint.to_string()) {
1881 self.held_records
1882 .set(self.held_records.get().saturating_sub(1));
1883 }
1884 }
1885
1886 /// A record of this (peer, topic) read from the storage: a fault record
1887 /// is known as one (engine 0.1.10).
1888 fn read_record(&self, record: Option<&DeferredGraph>) {
1889 if let Some(record) = record.filter(|r| r.is_struck()) {
1890 self.know_fault_record(&record.outpoint);
1891 }
1892 }
1893
1894 /// A record of this (peer, topic) deleted: one fewer fault record, or
1895 /// one fewer deferred slot held.
1896 fn forget_record(&self, outpoint: &str) {
1897 if !self.fault_records.borrow_mut().remove(outpoint) {
1898 self.held_records
1899 .set(self.held_records.get().saturating_sub(1));
1900 }
1901 }
1902
1903 /// Release the cursor's hold on `outpoint`: it is finished or held.
1904 fn release_hold(&mut self, outpoint: &str) {
1905 if self
1906 .cursor_held_by
1907 .as_ref()
1908 .is_some_and(|(_, held)| held == outpoint)
1909 {
1910 self.cursor_held_by = None;
1911 }
1912 }
1913
1914 fn hold_cursor(&mut self, score: u64, outpoint: &str) {
1915 if self
1916 .cursor_held_by
1917 .as_ref()
1918 .is_none_or(|(held, _)| score < *held)
1919 {
1920 self.cursor_held_by = Some((score, outpoint.to_string()));
1921 }
1922 }
1923
1924 fn root_expired(&self, node: &GASPNode, outpoint: &str) -> Result<bool, GASPError> {
1925 let now = (crate::engine::current_timestamp_ms() / 1000.0) as u64;
1926 let expiry = self.storage.sync_expiry(node, outpoint)?;
1927 if let Some(expiry) = expiry {
1928 self.root_expiries
1929 .borrow_mut()
1930 .insert(outpoint.to_string(), expiry);
1931 }
1932 let expiry = expiry.filter(|expiry| *expiry <= now);
1933 if expiry.is_some() {
1934 self.refused_expiry.set(expiry);
1935 }
1936 Ok(expiry.is_some())
1937 }
1938
1939 /// Load the remembered dead among `outpoints` into the run's memo; they
1940 /// are refused with no node fetch and are not counted again. A read
1941 /// fault is fail-safe: they are fetched and refused as before.
1942 async fn load_remembered_expired(&self, outpoints: Vec<String>) {
1943 let outpoints: Vec<String> = {
1944 let memo = self.expired.borrow();
1945 outpoints
1946 .into_iter()
1947 .filter(|o| !memo.contains(o))
1948 .collect()
1949 };
1950 if outpoints.is_empty() {
1951 return;
1952 }
1953 match self.storage.remembered_expired(&outpoints).await {
1954 Ok(dead) => self.expired.borrow_mut().extend(dead),
1955 Err(e) => warn!(
1956 "{} Could not read the remembered expired roots: {}",
1957 self.log_prefix, e
1958 ),
1959 }
1960 }
1961
1962 async fn refuse_expired(&self, outpoint: &str) -> Result<Ingested, GASPError> {
1963 let walk = self.walk.borrow_mut().take();
1964 let expiry = self.refused_expiry.take();
1965 if self.expired.borrow_mut().insert(outpoint.to_string()) {
1966 self.expired_refused.set(self.expired_refused.get() + 1);
1967 // Remembered across runs (0.1.8): the next sync skips it with no
1968 // node fetch. Best effort: a lost row costs one fetch again.
1969 if let Some(expiry) = expiry {
1970 if let Err(e) = self.storage.remember_expired(outpoint, expiry).await {
1971 warn!(
1972 "{} Could not remember expired root {}: {}",
1973 self.log_prefix, outpoint, e
1974 );
1975 }
1976 }
1977 }
1978 let _ = self
1979 .storage
1980 .discard_graph(&Self::graph_id_of(walk.as_ref(), outpoint))
1981 .await;
1982 if walk
1983 .as_ref()
1984 .is_some_and(|w| w.resumed || w.struck.is_some())
1985 || self.deferred.borrow_mut().remove(outpoint)
1986 {
1987 self.drop_record(outpoint, DropReason::Refused).await;
1988 }
1989 Ok(Ingested::Expired)
1990 }
1991
1992 /// Retire only fault facts whose own advert expired. No remote read.
1993 async fn sweep_expired_record(
1994 &self,
1995 record: Option<&DeferredGraph>,
1996 ) -> Result<bool, GASPError> {
1997 let Some(record) = record.filter(|r| r.is_unanchorable() || r.is_dead()) else {
1998 return Ok(false);
1999 };
2000 let outpoint = record.outpoint.as_str();
2001 self.read_record(Some(record));
2002 if record.expiry.is_none_or(|expiry| expiry > Self::now_secs())
2003 && !self.expired.borrow().contains(outpoint)
2004 {
2005 return Ok(false);
2006 }
2007 // Remember before deletion, so a later page cannot reopen an expired root.
2008 self.storage
2009 .remember_expired(outpoint, record.expiry.unwrap_or(Self::now_secs()))
2010 .await?;
2011 self.storage.delete_deferred_graph(outpoint).await?;
2012 self.forget_record(outpoint);
2013 self.deferred.borrow_mut().remove(outpoint);
2014 self.expired.borrow_mut().insert(outpoint.to_string());
2015 self.expired_swept.set(self.expired_swept.get() + 1);
2016 Ok(true)
2017 }
2018
2019 fn note_dead(&self, record: &DeferredGraph) {
2020 self.dead_recorded.set(self.dead_recorded.get() + 1);
2021 let mut faulted = self.faulted.borrow_mut();
2022 faulted.retain(|f| f.outpoint != record.outpoint);
2023 faulted.push(FaultedRoot {
2024 peer: String::new(),
2025 outpoint: record.outpoint.clone(),
2026 class: record.reason.clone(),
2027 faults: record.faults,
2028 unanchorable: true,
2029 error: format!("retry allowance spent: {}", record.unanchorable_retries),
2030 refused: String::new(),
2031 });
2032 warn!(
2033 "{} Root {} {} after {} retries",
2034 self.log_prefix, record.outpoint, record.reason, record.unanchorable_retries
2035 );
2036 }
2037
2038 async fn keep_unanchorable(&self, record: &DeferredGraph) -> Result<(), GASPError> {
2039 let mut kept = record.clone();
2040 kept.retry_at = Self::now_secs() + UNANCHORABLE_RETRY_SECS;
2041 if kept.unanchorable_retries >= UNANCHORABLE_RETRIES {
2042 kept.reason = format!(
2043 "{DEAD_REASON}{}",
2044 record.fault_class().unwrap_or(fault_class::OTHER)
2045 );
2046 kept.retry_at = 0;
2047 }
2048 if self.storage.save_deferred_graph(&kept).await? != DeferredGraphSave::Saved {
2049 return Err(GASPError::StorageError(
2050 "could not retain retry allowance".into(),
2051 ));
2052 }
2053 self.know_fault_record(&kept.outpoint);
2054 if kept.is_dead() && !record.is_dead() {
2055 self.note_dead(&kept);
2056 }
2057 Ok(())
2058 }
2059
2060 /// Unix seconds now, the clock of an unanchorable root's backoff.
2061 fn now_secs() -> u64 {
2062 (crate::engine::current_timestamp_ms() / 1000.0) as u64
2063 }
2064
2065 /// Mark the walk's failure with its fault class (engine 0.1.9) and hand
2066 /// the error back.
2067 fn faulted(&self, class: &'static str, error: GASPError) -> GASPError {
2068 if !matches!(error, GASPError::StorageError(_)) {
2069 self.last_fault.set(Some(class));
2070 }
2071 error
2072 }
2073
2074 /// The class of a peer's failure to serve a node: its `NodeNotFound`
2075 /// is its word that it does not hold the transaction (engine 0.1.15),
2076 /// any other failure is the moment's.
2077 fn peer_fault_class(error: &GASPError) -> &'static str {
2078 match error {
2079 GASPError::NodeNotFound(_) => fault_class::NOT_FOUND,
2080 GASPError::RouteFault { .. } => fault_class::ROUTE,
2081 _ => fault_class::PEER,
2082 }
2083 }
2084
2085 /// Name a dropped root once in this run's accounting (engine 0.1.9).
2086 fn note_faulted(
2087 &self,
2088 outpoint: &str,
2089 error: &GASPError,
2090 class: Option<&'static str>,
2091 struck: Strike,
2092 ) {
2093 let mut faulted = self.faulted.borrow_mut();
2094 if faulted.iter().any(|f| f.outpoint == outpoint) {
2095 return;
2096 }
2097 let (faults, unanchorable) = struck.held.unwrap_or((0, false));
2098 faulted.push(FaultedRoot {
2099 peer: String::new(),
2100 outpoint: outpoint.to_string(),
2101 class: class.unwrap_or(fault_class::OTHER).to_string(),
2102 faults,
2103 unanchorable,
2104 error: error.to_string().chars().take(160).collect(),
2105 refused: struck.refused.unwrap_or_default().to_string(),
2106 });
2107 }
2108
2109 /// A fresh root dropped in the page walk: count its faulted run when
2110 /// the failure is a fault and the root is the run's lowest gap, and name
2111 /// it either way. Whether it is now unanchorable (the cursor passes it).
2112 async fn strike_fresh(
2113 &self,
2114 score: u64,
2115 outpoint: &str,
2116 error: &GASPError,
2117 lowest: bool,
2118 ) -> bool {
2119 let class = self.last_fault.take();
2120 // Absence counts on every root of the run, not only the lowest
2121 // (0.1.12 for the rungs' word, 0.1.15 for the peer's): it is a
2122 // definite answer, one row of the bounded memo each (0.1.14), so a
2123 // page of absent roots passes together on its third gapped run.
2124 let struck = match class {
2125 Some(class)
2126 if (lowest || class == fault_class::NOT_FOUND)
2127 && self.graph_budget.is_some()
2128 && fault_class::strikes(class) =>
2129 {
2130 self.strike(score, outpoint, class, None).await
2131 }
2132 _ => Strike::default(),
2133 };
2134 self.note_faulted(outpoint, error, class, struck);
2135 struck.passable()
2136 }
2137
2138 /// A RESUMED walk of a held record dropped by a fault (engine 0.1.11):
2139 /// a root like any other, so a fault of a striking class
2140 /// ([`fault_class::strikes`]) counts a faulted run on the record's own
2141 /// slot ([`Self::strike`] with the record as `prior`: `faults`, the
2142 /// reason `retry:fault:<class>`, its score and its pass kept), and three
2143 /// make it `unanchorable:<class>`. Named in `faulted` with the count.
2144 /// `None` when no strike was written: no fault of the graph (a peer's
2145 /// fault of the moment, a storage fault, a bound), a pass that did not
2146 /// get the root (the peer no longer serves it: the record's own idle
2147 /// rule, engine 0.1.10), the record unread, or the strike refused
2148 /// (named `no_room` or `store_fault`); the caller then drops the record
2149 /// as before 0.1.11.
2150 async fn strike_resumed(
2151 &self,
2152 outpoint: &str,
2153 walk: Option<&Walk>,
2154 error: &GASPError,
2155 ) -> Option<Strike> {
2156 let class = self.last_fault.get().filter(|c| fault_class::strikes(c))?;
2157 // The root was never served this pass: the peer no longer offers
2158 // it, which says nothing of its graph.
2159 if walk.is_none_or(|w| w.record.nodes.is_empty()) {
2160 return None;
2161 }
2162 let prior = self.storage.get_deferred_graph(outpoint).await.ok()??;
2163 if prior.is_struck() {
2164 return None;
2165 }
2166 self.last_fault.set(None);
2167 let struck = self
2168 .strike(prior.score, outpoint, class, Some(&prior))
2169 .await;
2170 self.note_faulted(outpoint, error, Some(class), struck);
2171 struck.held.map(|_| struck)
2172 }
2173
2174 /// Count one more faulted run of `outpoint` (engine 0.1.9), on its
2175 /// record `prior` or on a new one: `retry:fault:<class>` (the cursor
2176 /// holds) under [`UNANCHORABLE_AFTER_FAULTED_RUNS`], `unanchorable:<class>`
2177 /// (passable, walked again after [`UNANCHORABLE_RETRY_SECS`]) at it. The
2178 /// record holds nothing but the root to ask for: the walk starts again
2179 /// from it. The storage keeps this compact fact in its bounded memo,
2180 /// separate from partial graphs. A strike that is not written (the
2181 /// storage's ceiling, a storage fault) is REFUSED: counted in
2182 /// [`Self::strikes_refused`] and named with why; a new root then stays a
2183 /// gap, a held unanchorable record stays as it was.
2184 async fn strike(
2185 &self,
2186 score: u64,
2187 outpoint: &str,
2188 class: &'static str,
2189 prior: Option<&DeferredGraph>,
2190 ) -> Strike {
2191 let refuse = |why: &'static str| {
2192 self.strikes_refused.set(self.strikes_refused.get() + 1);
2193 warn!(
2194 "{} Root {} faulted ({}): its strike was REFUSED ({}), the cursor holds (engine 0.1.10)",
2195 self.log_prefix, outpoint, class, why
2196 );
2197 Strike {
2198 // A held unanchorable record stays as it was: passable.
2199 held: prior
2200 .filter(|r| r.is_unanchorable())
2201 .map(|r| (r.faults, true)),
2202 refused: Some(why),
2203 }
2204 };
2205 let mut record = Self::fresh_walk(score, outpoint, PassCap::NONE).record;
2206 record.faults = prior.map_or(0, |r| r.faults).saturating_add(1);
2207 record.unanchorable_retries = prior.map_or(0, |r| r.unanchorable_retries);
2208 record.expiry = self
2209 .root_expiries
2210 .borrow()
2211 .get(outpoint)
2212 .copied()
2213 .or_else(|| prior.and_then(|r| r.expiry));
2214 // Engine 0.1.15: absence strikes on the three-run rule like every
2215 // class; on 0.1.12 to 0.1.14 the rungs' word struck at once.
2216 let unanchorable = record.faults >= UNANCHORABLE_AFTER_FAULTED_RUNS;
2217 // A record the cursor had passed stays passable (engine 0.1.11): the
2218 // strike counts its faulted runs and never takes the pass back.
2219 record.cursor_passable = unanchorable || prior.is_some_and(|r| r.cursor_passable);
2220 if unanchorable {
2221 record.reason = format!("{UNANCHORABLE_REASON}{class}");
2222 record.retry_at = Self::now_secs() + UNANCHORABLE_RETRY_SECS;
2223 } else {
2224 record.reason = format!("{FAULT_RETRY_REASON}{class}");
2225 // The next absence counts after the gap; until then the record
2226 // costs no request (engine 0.1.15).
2227 if class == fault_class::NOT_FOUND {
2228 record.retry_at = Self::now_secs() + ABSENCE_GAP_SECS;
2229 }
2230 }
2231 if unanchorable && record.unanchorable_retries >= UNANCHORABLE_RETRIES {
2232 record.reason = format!("{DEAD_REASON}{class}");
2233 record.retry_at = 0;
2234 }
2235 match self.storage.save_deferred_graph(&record).await {
2236 Ok(DeferredGraphSave::Saved) => {
2237 if record.is_dead() && !prior.is_some_and(DeferredGraph::is_dead) {
2238 self.note_dead(&record);
2239 }
2240 // A new key holds a fault record and no deferred slot; a
2241 // held record struck on its own slot frees its deferred
2242 // slot for the fault record (engine 0.1.11); a prior fault
2243 // record was known when it was read.
2244 if prior.is_some() {
2245 self.know_fault_record(outpoint);
2246 } else {
2247 self.fault_records.borrow_mut().insert(outpoint.to_string());
2248 }
2249 if unanchorable && !prior.is_some_and(DeferredGraph::is_unanchorable) {
2250 self.unanchorable_recorded
2251 .set(self.unanchorable_recorded.get() + 1);
2252 if class == fault_class::NOT_FOUND {
2253 self.absent_struck.set(self.absent_struck.get() + 1);
2254 }
2255 warn!(
2256 "{} Root {} UNANCHORABLE ({}): faulted {} runs in a row; the cursor passes it, walked again in {} s (engine 0.1.9)",
2257 self.log_prefix, outpoint, class, record.faults, UNANCHORABLE_RETRY_SECS
2258 );
2259 } else {
2260 warn!(
2261 "{} Root {} faulted ({}): run {} of {} (engine 0.1.9)",
2262 self.log_prefix,
2263 outpoint,
2264 class,
2265 record.faults,
2266 UNANCHORABLE_AFTER_FAULTED_RUNS
2267 );
2268 }
2269 Strike {
2270 held: Some((record.faults, unanchorable)),
2271 refused: None,
2272 }
2273 }
2274 Ok(DeferredGraphSave::AtCeiling) => refuse(strike_refused::NO_ROOM),
2275 Err(_) => refuse(strike_refused::STORE_FAULT),
2276 }
2277 }
2278
2279 /// Walk again the root of a fault record (engine 0.1.9), under this
2280 /// pass's resume allowance. An unanchorable one waits out its backoff
2281 /// (no request at all). The walk is fresh from the root and holds the
2282 /// record's slot: a landing or a verdict clears the record, a budget
2283 /// cut or a deadline replaces it with the hold it earned, and a fault
2284 /// counts one more run on it.
2285 async fn rewalk_struck(
2286 &self,
2287 utxo: &GASPOutput,
2288 outpoint: &str,
2289 mut record: DeferredGraph,
2290 deadline: &mut crate::engine::SleepFuture,
2291 cap: PassCap,
2292 ) -> Result<Ingested, GASPError> {
2293 let score = utxo.score as u64;
2294 let absent = record.fault_class() == Some(fault_class::NOT_FOUND);
2295 if record.is_dead() || (record.is_unanchorable() && Self::now_secs() < record.retry_at) {
2296 // Not asked of the peer: not an attempt for the yieldless
2297 // streak (a quiet peer holding one is never quarantined for it).
2298 self.graphs_attempted
2299 .set(self.graphs_attempted.get().saturating_sub(1));
2300 if absent {
2301 crate::meter::peer_absent_memo_hit();
2302 }
2303 debug!(
2304 "{} Unanchorable root {} waits until {} (engine 0.1.9)",
2305 self.log_prefix, outpoint, record.retry_at
2306 );
2307 return Ok(Ingested::Unanchorable);
2308 }
2309 // Engine 0.1.15: an absence counted less than ABSENCE_GAP_SECS ago
2310 // is not asked again; the run counts nothing and the cursor holds
2311 // below a root it has not passed.
2312 if !record.is_unanchorable() && Self::now_secs() < record.retry_at {
2313 self.graphs_attempted
2314 .set(self.graphs_attempted.get().saturating_sub(1));
2315 if absent {
2316 crate::meter::peer_absent_memo_hit();
2317 }
2318 debug!(
2319 "{} Absent root {} counted {} runs; the next count waits until {} (engine 0.1.15)",
2320 self.log_prefix, outpoint, record.faults, record.retry_at
2321 );
2322 return Ok(if record.cursor_passable {
2323 Ingested::Deferred
2324 } else {
2325 Ingested::Retry
2326 });
2327 }
2328 if record.is_unanchorable() {
2329 if record.unanchorable_retries >= UNANCHORABLE_RETRIES {
2330 self.keep_unanchorable(&record).await?;
2331 self.graphs_attempted
2332 .set(self.graphs_attempted.get().saturating_sub(1));
2333 return Ok(Ingested::Unanchorable);
2334 }
2335 // Reserve before dispatch: cancellation cannot buy another retry.
2336 record.unanchorable_retries += 1;
2337 record.retry_at = Self::now_secs() + UNANCHORABLE_RETRY_SECS;
2338 if !self.storage.reserve_unanchorable_retry(&record).await? {
2339 self.graphs_attempted
2340 .set(self.graphs_attempted.get().saturating_sub(1));
2341 return Ok(Ingested::Unanchorable);
2342 }
2343 }
2344 info!(
2345 "{} Walking root {} again after {} faulted runs ({}) (engine 0.1.9)",
2346 self.log_prefix, outpoint, record.faults, record.reason
2347 );
2348 crate::meter::walk_output();
2349 let mut walk = Self::fresh_walk(score, outpoint, cap);
2350 walk.struck = Some(Box::new(record.clone()));
2351 *self.walk.borrow_mut() = Some(walk);
2352 let ended = self.walk_graph(Some(&mut *deadline)).await;
2353 let out = self.finish_pass(outpoint, ended, deadline).await;
2354 match out {
2355 Ok(ingested)
2356 if record.is_unanchorable()
2357 && !matches!(ingested, Ingested::Completed | Ingested::Expired) =>
2358 {
2359 self.keep_unanchorable(&record).await?;
2360 Ok(Ingested::Unanchorable)
2361 }
2362 // A stall of the graph's own deadline does not undo the record:
2363 // the empty retry `save_walk` wrote is replaced again. So for a
2364 // passable fault record struck on a held record's slot (engine
2365 // 0.1.11): the empty retry would hold the cursor it had passed.
2366 Ok(Ingested::Retry) if record.cursor_passable => {
2367 let unanchorable = record.is_unanchorable();
2368 let kept = record.clone();
2369 if let Ok(DeferredGraphSave::Saved) = self.storage.save_deferred_graph(&kept).await
2370 {
2371 // A fault record again, not a deferred slot (0.1.10).
2372 self.know_fault_record(outpoint);
2373 }
2374 Ok(if unanchorable {
2375 Ingested::Unanchorable
2376 } else {
2377 Ingested::Deferred
2378 })
2379 }
2380 Ok(ingested) => Ok(ingested),
2381 Err(error) => {
2382 self.walk.borrow_mut().take();
2383 let class = self.last_fault.take();
2384 let Some(class) = class.filter(|c| fault_class::strikes(c)) else {
2385 // No fault of the graph's: the record stands.
2386 self.note_faulted(
2387 outpoint,
2388 &error,
2389 class,
2390 Strike {
2391 held: Some((record.faults, record.is_unanchorable())),
2392 refused: None,
2393 },
2394 );
2395 if record.is_unanchorable() {
2396 self.keep_unanchorable(&record).await?;
2397 return Ok(Ingested::Unanchorable);
2398 }
2399 return Err(error);
2400 };
2401 let struck = self.strike(score, outpoint, class, Some(&record)).await;
2402 self.note_faulted(outpoint, &error, Some(class), struck);
2403 if struck.passable() {
2404 Ok(Ingested::Unanchorable)
2405 } else {
2406 Err(error)
2407 }
2408 }
2409 }
2410 }
2411
2412 /// Bound each graph's walk and retain its pending work. A successfully
2413 /// saved hold permits cursor progress; an unsaved graph remains a gap.
2414 /// Bound the resumes of each pass by `share` (engine 0.1.11): ONE
2415 /// deadline for every held record walked ahead of the page, started when
2416 /// the held loop starts, so the page keeps the rest of the per-peer
2417 /// slice (see [`RESUMES_SHARE_OF_PEER_BUDGET_PERCENT`]). A record reached
2418 /// once it fell due is held back for the next sync. Without one the
2419 /// resumes share one per-graph deadline (the lens fold's M1).
2420 #[must_use]
2421 pub fn with_resume_share(
2422 mut self,
2423 share: Box<dyn Fn() -> crate::engine::SleepFuture + 'a>,
2424 ) -> Self {
2425 self.resume_share = Some(share);
2426 self
2427 }
2428
2429 pub fn with_graph_budget(mut self, budget: GraphBudget<'a>) -> Self {
2430 self.graph_budget = Some(budget);
2431 self
2432 }
2433
2434 /// What the last `sync` did with deferred graphs (bsv-low #555).
2435 pub fn deferral_stats(&self) -> DeferralStats {
2436 self.stats.borrow().clone()
2437 }
2438
2439 /// How many UTXOs the sync set out to ingest (a new walk, a resume, or a
2440 /// record held back): the peer served it work it did not hold. A sync
2441 /// that attempted none was quiet (bsv-low #555, the delta fold's D-M2:
2442 /// a quiet sync never counts toward [`PEER_YIELDLESS_SYNCS_ALLOWED`]).
2443 pub fn graphs_attempted(&self) -> u64 {
2444 self.graphs_attempted.get()
2445 }
2446
2447 /// Have `hook` called after every completed incoming UTXO (bsv-low #552,
2448 /// see [`FinalizedGraphHook`]). Without one `sync` is unchanged.
2449 #[must_use]
2450 pub fn with_finalized_graph_hook(mut self, hook: Box<dyn FinalizedGraphHook + 'a>) -> Self {
2451 self.finalized_hook = Some(hook);
2452 self
2453 }
2454
2455 /// The cursor that is safe to persist if `sync` stopped RIGHT NOW (its
2456 /// future dropped at a deadline; bsv-low #552). It is the gap guard's
2457 /// rule applied to completed scores: never above a completed output, and
2458 /// strictly below the lowest score of
2459 /// any UTXO of the current page not yet completed (the one in flight
2460 /// included) and of any UTXO whose ingest failed, never below the cursor
2461 /// the sync entered with. The remote serves `score >= since`, so the next
2462 /// sync is served everything this one did not finish. After a completed
2463 /// `sync` it equals `last_interaction`.
2464 ///
2465 /// `last_interaction` itself is NOT safe after a drop: it is raised to
2466 /// each UTXO's score BEFORE that UTXO is ingested, for pagination.
2467 pub fn completed_cursor(&self) -> u64 {
2468 self.completed_cursor
2469 }
2470
2471 /// Whether a graph was being fetched, walked or anchor-checked when `sync`
2472 /// last stopped: `true` only after a `sync` future dropped mid-graph
2473 /// (bsv-low #552). That graph's nodes died with the storage adapter;
2474 /// nothing of it was finalized.
2475 pub fn graph_in_flight(&self) -> bool {
2476 self.graph_in_flight
2477 }
2478
2479 /// Highest completed score below every gap, floored at the entry cursor.
2480 /// Retaining scores handles unordered pages without inventing a cursor
2481 /// between outputs or skipping an unfinished member of a score tie.
2482 fn cursor_for_completed(
2483 completed: &std::collections::BTreeSet<u64>,
2484 lowest_unfinished: Option<u64>,
2485 floor: u64,
2486 ) -> u64 {
2487 let highest = match lowest_unfinished {
2488 Some(score) => completed.range(..score).next_back(),
2489 None => completed.iter().next_back(),
2490 };
2491 highest.copied().unwrap_or(floor).max(floor)
2492 }
2493
2494 /// How many graphs this orchestrator's last `sync` discarded because
2495 /// `validate_graph_anchor` REFUSED them (a root that does not verify, or
2496 /// one the topic manager does not admit at the end of the replay; bsv-low
2497 /// #551). Nothing of a discarded graph is finalized. As in the reference
2498 /// a refused graph is not a failed UTXO: the cursor advances past it. A
2499 /// graph whose anchor could not be CHECKED
2500 /// (`GASPError::AnchorUnavailable`: a tracker or storage fault, a topic
2501 /// manager error in the replay) is not counted here: that one fails its
2502 /// UTXO and is asked for again.
2503 pub fn discarded_graphs(&self) -> u64 {
2504 self.discarded_graphs.get()
2505 }
2506
2507 /// How many graphs this orchestrator's last `sync` HELD because their
2508 /// anchor's script walk could not run within the engine's walk budget
2509 /// (`GASPError::AnchorWalkHeld`; bsv-low #592, the E592 lens fold): not
2510 /// finalized, the UTXO failed and the cursor kept below it, until the
2511 /// peer serves the root proven. Not counted in `discarded_graphs`.
2512 pub fn anchor_walk_held(&self) -> u64 {
2513 self.anchor_walk_held.get()
2514 }
2515
2516 /// How many manager-named inputs this orchestrator pruned because the
2517 /// peer answered that it does not hold them (the D8 decoy rule, see
2518 /// `process_incoming_node`). Counted per DISTINCT outpoint per `sync`
2519 /// call of this orchestrator, which talks to ONE peer: the same decoy
2520 /// seen through two peers is counted by each. A pruned branch is NOT a
2521 /// failed UTXO: the graph completes with what it has and the cursor
2522 /// advances.
2523 pub fn pruned_inputs(&self) -> u64 {
2524 self.pruned_inputs.get()
2525 }
2526
2527 /// Opt in to chain-backed ancestor hydration (OFF by default).
2528 ///
2529 /// When set, a peer's inability to serve a needed ancestor during ingest
2530 /// triggers a fallback fetch of that ancestor's raw tx from chain. This is
2531 /// a deliberate one-time-migration escape hatch — production should NOT
2532 /// call this. Without it, behavior is unchanged.
2533 #[must_use]
2534 pub fn with_ancestor_fetcher(
2535 mut self,
2536 fetcher: Option<std::rc::Rc<dyn AncestorFetcher + 'a>>,
2537 ) -> Self {
2538 self.ancestor_fetcher = fetcher;
2539 self
2540 }
2541
2542 /// Run the sync protocol with the remote peer.
2543 ///
2544 /// 1. Request remote's UTXOs since last interaction (paginated)
2545 /// 2. For each unknown UTXO, request the full graph and ingest it
2546 /// 3. If bidirectional, push our unknown UTXOs to the remote
2547 pub async fn sync(&mut self, limit: Option<u64>) -> Result<(), GASPError> {
2548 info!(
2549 "{} Starting sync. last_interaction={}",
2550 self.log_prefix, self.last_interaction
2551 );
2552 self.page.pages = 0;
2553 self.page.advanced = false;
2554 self.page.reached_tip = false;
2555 self.pruned.borrow_mut().clear();
2556 self.pruned_inputs.set(0);
2557 self.absent.borrow_mut().clear();
2558 self.batch.queue.borrow_mut().clear();
2559 self.batch.prefetched.borrow_mut().clear();
2560 self.expired_refused.set(0);
2561 self.root_proofs.borrow_mut().clear();
2562 self.roots_proven.set(0);
2563 self.roots_unproven.set(0);
2564 self.cursor_held_by = None;
2565 self.in_flight = None;
2566 self.retained.set(0);
2567 self.resumed_with_progress.set(0);
2568 self.cursor_yielded.set(false);
2569 self.last_fault.set(None);
2570 self.faulted.borrow_mut().clear();
2571 self.unanchorable_recorded.set(0);
2572 self.unanchorable_cleared.set(0);
2573 self.absent_struck.set(0);
2574 self.dead_recorded.set(0);
2575 self.expired_swept.set(0);
2576 self.memo_folded.set(0);
2577 self.root_expiries.borrow_mut().clear();
2578 self.strikes_refused.set(0);
2579 self.fault_records.borrow_mut().clear();
2580 self.discarded_graphs.set(0);
2581 self.anchor_walk_held.set(0);
2582 self.completed_cursor = self.last_interaction;
2583 self.graph_in_flight = false;
2584 self.walk.borrow_mut().take();
2585 *self.stats.borrow_mut() = DeferralStats::default();
2586 self.deferred.borrow_mut().clear();
2587 self.held_records.set(0);
2588 self.resume_deadline.borrow_mut().take();
2589 self.resume_left.set(
2590 self.graph_budget
2591 .as_ref()
2592 .map_or(PassCap::NONE, GraphBudget::pass),
2593 );
2594 // Load only keys here. Records are resumed independently of pages,
2595 // one at a time under the shared resume allowance. A key-read fault
2596 // conservatively reserves all slots for the records already held.
2597 let mut held_keys = Vec::new();
2598 if self.graph_budget.is_some() {
2599 let memo = self.storage.read_fault_memos(None).await?;
2600 self.memo_folded.set(memo.folded);
2601 self.expired_swept.set(memo.swept);
2602 match self.storage.load_deferred_graphs().await {
2603 Ok(keys) => {
2604 held_keys.clone_from(&keys);
2605 self.held_records.set(keys.len());
2606 self.deferred
2607 .borrow_mut()
2608 .extend(keys.into_iter().map(|k| k.outpoint));
2609 }
2610 Err(e) => {
2611 self.held_records.set(DEFERRED_GRAPHS_PER_PEER_TOPIC);
2612 warn!(
2613 "{} Could not read the deferred graphs (bsv-low #555): {}",
2614 self.log_prefix, e
2615 );
2616 }
2617 }
2618 for record in memo.records {
2619 held_keys.retain(|key| key.outpoint != record.outpoint);
2620 self.fault_records
2621 .borrow_mut()
2622 .insert(record.outpoint.clone());
2623 self.deferred.borrow_mut().insert(record.outpoint.clone());
2624 held_keys.push(DeferredGraphKey {
2625 outpoint: record.outpoint,
2626 score: record.score,
2627 });
2628 }
2629 held_keys.sort_by(|a, b| (a.score, &a.outpoint).cmp(&(b.score, &b.outpoint)));
2630 }
2631
2632 // Track what we already know
2633 let local_utxos = self.storage.find_known_utxos(0, None).await?;
2634 let mut known_outpoints: std::collections::HashSet<String> = local_utxos
2635 .iter()
2636 .map(|u| format!("{}.{}", u.txid, u.output_index))
2637 .collect();
2638 let mut shared_outpoints: std::collections::HashSet<String> =
2639 std::collections::HashSet::new();
2640
2641 // The cursor we entered with. The persisted cursor must never regress
2642 // below it (a failed RE-ingest of an already-synced range is not a new
2643 // gap), and the gap-guard below is floored at it.
2644 let initial_interaction = self.last_interaction;
2645 let mut completed_scores = std::collections::BTreeSet::new();
2646 // Lowest score of any UTXO whose graph ingest FAILED this run. The
2647 // in-loop `last_interaction` advance below is by max-seen-score so
2648 // pagination terminates, but a transient ingest failure must NOT let the
2649 // PERSISTED cursor skip past that output — else the peer never re-serves
2650 // it (next `since` excludes scores below the cursor) and it is stranded
2651 // forever. After the loop we cap the cursor strictly below this score so
2652 // the next sync re-requests the failed graph. (Without this, a single
2653 // blipped graph fetch silently drops that UTXO from continuous sync.)
2654 let mut min_failed_score: Option<u64> = None;
2655 // The UTXOs whose ingest FAILED this run (bsv-low #554). The cursor
2656 // moves to a UTXO's score before its ingest and the responder serves
2657 // `score >= since`, so the row at a page boundary is served again on
2658 // the next page of this same sync. A completed one is in
2659 // `shared_outpoints` and skipped; a failed one was in no set and was
2660 // walked a second time, at twice the requests and twice the per-peer
2661 // budget. An addition to the reference: `GASP.ts sync` keeps only
2662 // `sharedOutpoints` (added on success), so a failed UTXO served
2663 // again by a later page is ingested again there. Here a UTXO is
2664 // ingested at most once per sync; the retry is the next sync's, which
2665 // the gap guard below makes sure is served it. Kept apart from
2666 // `shared_outpoints`, which also decides what a bidirectional sync
2667 // pushes.
2668 let mut failed_outpoints: std::collections::HashSet<String> =
2669 std::collections::HashSet::new();
2670
2671 // Saved graphs are work independent of the peer's pagination cursor.
2672 // Read one record at a time and share the existing resume allowance.
2673 // A held root remembered dead is refused with no fetch.
2674 self.load_remembered_expired(held_keys.iter().map(|k| k.outpoint.clone()).collect())
2675 .await;
2676 // Engine 0.1.11: the resumes' share starts with the held loop, so the
2677 // storage's calls between resumes are inside it (polled once now, so
2678 // a timer that starts when it is polled runs).
2679 if !held_keys.is_empty() && self.graph_budget.is_some() {
2680 if let Some(share) = &self.resume_share {
2681 let mut deadline = latched(share());
2682 let _ = Self::due(&mut deadline).await;
2683 *self.resume_deadline.borrow_mut() = Some(deadline);
2684 }
2685 }
2686 let held_scores: std::collections::HashMap<String, u64> = held_keys
2687 .iter()
2688 .map(|key| (key.outpoint.clone(), key.score))
2689 .collect();
2690 for key in held_keys {
2691 if self.request_stopped() {
2692 break;
2693 }
2694 if known_outpoints.contains(&key.outpoint) {
2695 self.deferred.borrow_mut().remove(&key.outpoint);
2696 self.drop_record(&key.outpoint, DropReason::Held).await;
2697 shared_outpoints.insert(key.outpoint);
2698 continue;
2699 }
2700 let Some((txid, output_index)) = parse_outpoint(&key.outpoint) else {
2701 continue;
2702 };
2703 let utxo = GASPOutput {
2704 txid,
2705 output_index,
2706 score: key.score as f64,
2707 };
2708 self.graph_in_flight = true;
2709 self.in_flight = Some((key.score, key.outpoint.clone()));
2710 self.cursor_after_in_flight = initial_interaction;
2711 self.hold_cursor(key.score, &key.outpoint);
2712 let result = self.ingest_utxo(&utxo, &key.outpoint).await;
2713 self.graph_in_flight = false;
2714 self.in_flight = None;
2715 let settled = match result {
2716 Ok(Ingested::Completed) => {
2717 let landed = match &self.finalized_hook {
2718 Some(hook) => hook.graph_completed().await.is_ok(),
2719 None => true,
2720 };
2721 self.settle_completed(landed).await;
2722 if landed {
2723 self.cursor_yielded.set(true);
2724 }
2725 landed
2726 }
2727 Ok(Ingested::Expired) => {
2728 self.cursor_yielded.set(true);
2729 true
2730 }
2731 Ok(
2732 Ingested::Deferred
2733 | Ingested::HeldBack
2734 | Ingested::Unanchorable
2735 | Ingested::Waiting,
2736 ) => true,
2737 Ok(Ingested::Retry) => false,
2738 Err(error) => {
2739 warn!(
2740 "{} Deferred graph {} retry failed: {}",
2741 self.log_prefix, key.outpoint, error
2742 );
2743 self.walk.borrow_mut().take();
2744 let class = self.last_fault.take();
2745 self.note_faulted(&key.outpoint, &error, class, Strike::default());
2746 false
2747 }
2748 };
2749 let settled = settled
2750 || self
2751 .storage
2752 .get_deferred_graph(&key.outpoint)
2753 .await
2754 .ok()
2755 .flatten()
2756 .is_some_and(|r| r.cursor_passable);
2757 if settled {
2758 if self
2759 .cursor_held_by
2760 .as_ref()
2761 .is_some_and(|(_, held)| held == &key.outpoint)
2762 {
2763 self.cursor_held_by = None;
2764 }
2765 // A saved graph settles only itself. The current page must
2766 // account for any earlier unsaved gap before its score can
2767 // contribute to cursor movement.
2768 shared_outpoints.insert(key.outpoint);
2769 } else {
2770 self.hold_cursor(key.score, &key.outpoint);
2771 if key.score >= initial_interaction {
2772 min_failed_score =
2773 Some(min_failed_score.map_or(key.score, |s| s.min(key.score)));
2774 }
2775 failed_outpoints.insert(key.outpoint);
2776 }
2777 }
2778
2779 // Paginated pull from remote
2780 'pages: loop {
2781 if self.request_stopped() {
2782 break;
2783 }
2784 let cursor_before_page = self.last_interaction;
2785 // Every UTXO seen so far is completed or failed: were the sync
2786 // dropped inside the page request below, this is what it did.
2787 self.completed_cursor = Self::cursor_for_completed(
2788 &completed_scores,
2789 min_failed_score,
2790 initial_interaction,
2791 );
2792 let request = GASPInitialRequest {
2793 version: GASP_VERSION,
2794 since: self.last_interaction,
2795 limit,
2796 };
2797 let response = self.remote.get_initial_response(&request).await?;
2798 let page_size = response.utxo_list.len();
2799 self.page.pages += 1;
2800
2801 info!(
2802 "{} Processing page with {} UTXOs (since: {})",
2803 self.log_prefix, page_size, response.since
2804 );
2805
2806 // The page's remembered dead are refused with no node fetch.
2807 self.load_remembered_expired(
2808 response
2809 .utxo_list
2810 .iter()
2811 .map(|u| format!("{}.{}", u.txid, u.output_index))
2812 .filter(|o| !known_outpoints.contains(o))
2813 .collect(),
2814 )
2815 .await;
2816
2817 if self.graph_budget.is_some() {
2818 let outpoints: Vec<_> = response
2819 .utxo_list
2820 .iter()
2821 .map(|u| format!("{}.{}", u.txid, u.output_index))
2822 .collect();
2823 let memo = self.storage.read_fault_memos(Some(&outpoints)).await?;
2824 for record in memo.records {
2825 let changed = response.utxo_list.iter().any(|u| {
2826 format!("{}.{}", u.txid, u.output_index) == record.outpoint
2827 && u.score as u64 != record.score
2828 });
2829 if changed && (record.dead || record.unanchorable) {
2830 shared_outpoints.remove(&record.outpoint);
2831 failed_outpoints.remove(&record.outpoint);
2832 }
2833 self.fault_records
2834 .borrow_mut()
2835 .insert(record.outpoint.clone());
2836 self.deferred.borrow_mut().insert(record.outpoint);
2837 }
2838 }
2839
2840 // Engine 0.2.1 (#58): the outputs this page will ask the peer for
2841 // as fresh roots, so a peer that answers the batch is asked for
2842 // them in as few bodies as the budget allows.
2843 self.queue_batch(
2844 response
2845 .utxo_list
2846 .iter()
2847 .map(|u| format!("{}.{}", u.txid, u.output_index)),
2848 |o| {
2849 !known_outpoints.contains(o)
2850 && !shared_outpoints.contains(o)
2851 && !failed_outpoints.contains(o)
2852 },
2853 );
2854
2855 // lowest_ahead[i]: the lowest score among this page's UTXOs from
2856 // i on, for `completed_cursor` (a page is not assumed sorted).
2857 let mut lowest_ahead: Vec<u64> =
2858 response.utxo_list.iter().map(|u| u.score as u64).collect();
2859 for i in (0..lowest_ahead.len().saturating_sub(1)).rev() {
2860 lowest_ahead[i] = lowest_ahead[i].min(lowest_ahead[i + 1]);
2861 }
2862
2863 // The request may have returned an unordered page. Hold below all
2864 // its unfinished outputs before any graph or storage await.
2865 let unfinished = match lowest_ahead.as_slice() {
2866 [lowest, ..] => Some(min_failed_score.map_or(*lowest, |score| score.min(*lowest))),
2867 [] => min_failed_score,
2868 };
2869 self.completed_cursor =
2870 Self::cursor_for_completed(&completed_scores, unfinished, initial_interaction);
2871
2872 for (position, utxo) in response.utxo_list.iter().enumerate() {
2873 if self.request_stopped() {
2874 let score = lowest_ahead[position];
2875 min_failed_score = Some(min_failed_score.map_or(score, |s| s.min(score)));
2876 break 'pages;
2877 }
2878 // Track highest score for pagination
2879 if utxo.score as u64 > self.last_interaction {
2880 self.last_interaction = utxo.score as u64;
2881 }
2882
2883 let outpoint = format!("{}.{}", utxo.txid, utxo.output_index);
2884 if held_scores
2885 .get(&outpoint)
2886 .is_some_and(|score| *score != utxo.score as u64)
2887 && self
2888 .storage
2889 .get_deferred_graph(&outpoint)
2890 .await?
2891 .is_some_and(|r| r.is_unanchorable() || r.is_dead())
2892 {
2893 shared_outpoints.remove(&outpoint);
2894 failed_outpoints.remove(&outpoint);
2895 self.deferred.borrow_mut().insert(outpoint.clone());
2896 }
2897 if known_outpoints.contains(&outpoint) {
2898 self.cursor_yielded.set(true);
2899 // A deferred graph whose UTXO the node now holds (it
2900 // landed another way): nothing to resume.
2901 if self.deferred.borrow_mut().remove(&outpoint) {
2902 self.drop_record(&outpoint, DropReason::Held).await;
2903 }
2904 shared_outpoints.insert(outpoint.clone());
2905 known_outpoints.remove(&outpoint);
2906 } else if !shared_outpoints.contains(&outpoint)
2907 && !failed_outpoints.contains(&outpoint)
2908 {
2909 // New UTXO — request and ingest the graph
2910 let lowest_unfinished = min_failed_score
2911 .map_or(lowest_ahead[position], |f| f.min(lowest_ahead[position]));
2912 self.completed_cursor = Self::cursor_for_completed(
2913 &completed_scores,
2914 Some(lowest_unfinished),
2915 initial_interaction,
2916 );
2917 let mut with_current = completed_scores.clone();
2918 with_current.insert(utxo.score as u64);
2919 let remaining = lowest_ahead
2920 .get(position + 1)
2921 .copied()
2922 .into_iter()
2923 .chain(min_failed_score)
2924 .min();
2925 self.cursor_after_in_flight =
2926 Self::cursor_for_completed(&with_current, remaining, initial_interaction);
2927 self.hold_cursor(utxo.score as u64, &outpoint);
2928 self.graph_in_flight = true;
2929 self.in_flight = Some((utxo.score as u64, outpoint.clone()));
2930 let ingested = self.ingest_utxo(utxo, &outpoint).await;
2931 self.graph_in_flight = false;
2932 self.in_flight = None;
2933 let ingested = match ingested {
2934 Ok(Ingested::Expired) => {
2935 self.cursor_yielded.set(true);
2936 Ok(true)
2937 }
2938 Ok(Ingested::Completed) => {
2939 let landed = match &self.finalized_hook {
2940 Some(hook) => hook.graph_completed().await,
2941 None => Ok(()),
2942 };
2943 self.settle_completed(landed.is_ok()).await;
2944 if landed.is_ok() {
2945 self.cursor_yielded.set(true);
2946 }
2947 landed.map(|()| true)
2948 }
2949 Ok(
2950 Ingested::Deferred
2951 | Ingested::HeldBack
2952 | Ingested::Unanchorable
2953 | Ingested::Waiting,
2954 ) => Ok(true),
2955 Ok(Ingested::Retry) => Ok(false),
2956 Err(e) => {
2957 self.walk.borrow_mut().take();
2958 Err(e)
2959 }
2960 };
2961 match ingested {
2962 Ok(true) => {
2963 if self
2964 .cursor_held_by
2965 .as_ref()
2966 .is_some_and(|(_, held)| held == &outpoint)
2967 {
2968 self.cursor_held_by = None;
2969 }
2970 shared_outpoints.insert(outpoint.clone());
2971 }
2972 // An empty deadline retry has not earned a durable
2973 // hold yet; retain the gap until its named threshold.
2974 Ok(false) => {
2975 let s = utxo.score as u64;
2976 min_failed_score = Some(min_failed_score.map_or(s, |cur| cur.min(s)));
2977 failed_outpoints.insert(outpoint.clone());
2978 }
2979 Err(e) => {
2980 warn!(
2981 "{} Error ingesting UTXO {}: {}",
2982 self.log_prefix, outpoint, e
2983 );
2984 let s = utxo.score as u64;
2985 // Engine 0.1.9: a FAULT of the lowest gap of the
2986 // run counts the root's opening faulted run (a
2987 // record `retry:fault:<class>`); its next runs
2988 // walk it ahead of the pages, and the cursor
2989 // passes it once it is recorded unanchorable.
2990 // Only the lowest: one new record a (peer, topic)
2991 // a run, so a peer that faults every node cannot
2992 // fill the store in one tick (the delta fold's
2993 // D-M1).
2994 let lowest = min_failed_score.is_none_or(|m| s < m);
2995 if self.strike_fresh(s, &outpoint, &e, lowest).await {
2996 self.release_hold(&outpoint);
2997 shared_outpoints.insert(outpoint.clone());
2998 } else {
2999 // Remember the lowest failed score so the
3000 // persisted cursor cannot skip past it (see
3001 // cap below).
3002 min_failed_score =
3003 Some(min_failed_score.map_or(s, |cur| cur.min(s)));
3004 // Not again in this sync (bsv-low #554).
3005 failed_outpoints.insert(outpoint.clone());
3006 }
3007 }
3008 }
3009 }
3010 // Known outputs and graphs whose finalize hook landed are
3011 // settled. Explicit anchor refusals retain the existing
3012 // terminal-discard rule. Saved holds can be passed; faults
3013 // without a durable hold retain a gap.
3014 if shared_outpoints.contains(&outpoint) {
3015 completed_scores.insert(utxo.score as u64);
3016 }
3017 let unfinished = lowest_ahead
3018 .get(position + 1)
3019 .copied()
3020 .into_iter()
3021 .chain(min_failed_score)
3022 .min();
3023 self.completed_cursor =
3024 Self::cursor_for_completed(&completed_scores, unfinished, initial_interaction);
3025 }
3026
3027 // Keep the settled frontier and any completed scores above a gap.
3028 // An advancing page needs no older score again.
3029 completed_scores.retain(|score| *score >= self.completed_cursor);
3030
3031 // Pagination termination (bsv-low #291 gate findings M1/LOW-A):
3032 // keep paging on ANY page that advanced the score cursor —
3033 // with or without a requested limit. The old rule ("continue
3034 // only on a full page"; `None` never paged at all) silently
3035 // terminated after one page against a responder that CLAMPS
3036 // the page size (Engine::clamp_sync_limit clamps BOTH an
3037 // over-large and an ABSENT limit) — a clamped page is never
3038 // "full" by our limit, yet more rows remain. A non-advancing
3039 // page (empty, or only rows at scores we already hold — the
3040 // responder serves `score >= since`, so the boundary row is
3041 // re-served) is the true completion signal: no further request
3042 // can make progress.
3043 //
3044 // Known residual (gate LOW-B, documented not fixed): the
3045 // cursor is the bare GASP `since` score, so a tie group of
3046 // IDENTICAL scores at least as large as the responder's page
3047 // would end the run with that group's tail unreached (break on
3048 // non-advance — a silent gap, never a spin). A compound
3049 // (score, rowid) cursor would need a wire change to `since`,
3050 // which is out of bounds; unconstructible in practice — see
3051 // the responder-side note at
3052 // `D1Storage::find_utxos_for_topic`.
3053 if self.last_interaction == cursor_before_page {
3054 let settled = min_failed_score.is_none()
3055 && response.utxo_list.iter().all(|u| {
3056 shared_outpoints.contains(&format!("{}.{}", u.txid, u.output_index))
3057 });
3058 if self.request_stop.is_some() && settled && !response.utxo_list.is_empty() {
3059 // Inclusive peers repeat the boundary. Confirm an empty page above
3060 // it before claiming tip, without ever storing the probe cursor.
3061 // A clamped boundary can conceal later rows: retain our cursor
3062 // and report no progress when the probe finds any.
3063 if !self.request_stopped() {
3064 if let Some(since) = self.last_interaction.checked_add(1) {
3065 let probe = self
3066 .remote
3067 .get_initial_response(&GASPInitialRequest {
3068 version: GASP_VERSION,
3069 since,
3070 limit,
3071 })
3072 .await?;
3073 self.page.pages += 1;
3074 self.page.reached_tip = probe.utxo_list.is_empty();
3075 }
3076 }
3077 } else {
3078 self.page.reached_tip = settled;
3079 }
3080 break;
3081 }
3082 self.page.advanced = true;
3083 if self.request_stop.is_some() {
3084 break;
3085 }
3086 }
3087
3088 // Gap-guard: if any graph ingest failed transiently, cap the cursor
3089 // strictly below the lowest failed score so the next sync re-pulls it
3090 // (the remote serves `score >= since`). Floored at `initial_interaction`
3091 // so the cursor never regresses — a failure within the already-synced
3092 // range is not a new gap and must not rewind the cursor.
3093 self.completed_cursor =
3094 Self::cursor_for_completed(&completed_scores, min_failed_score, initial_interaction);
3095 if let Some(failed) = min_failed_score {
3096 if self.last_interaction > self.completed_cursor {
3097 warn!(
3098 "{} Capping cursor {} -> {} (transient ingest failure at score {}); next sync will re-pull",
3099 self.log_prefix, self.last_interaction, self.completed_cursor, failed
3100 );
3101 }
3102 }
3103 self.last_interaction = self.completed_cursor;
3104
3105 // A saved graph can now lie below the peer cursor. Absence from a
3106 // page is not evidence that its work may be deleted.
3107
3108 // Bidirectional: push our UTXOs to remote
3109 if !self.unidirectional {
3110 // Find local UTXOs the remote doesn't have
3111 for utxo in &local_utxos {
3112 let outpoint = format!("{}.{}", utxo.txid, utxo.output_index);
3113 if !shared_outpoints.contains(&outpoint) {
3114 match self.push_utxo(utxo).await {
3115 Ok(()) => {}
3116 Err(e) => {
3117 warn!("{} Error pushing UTXO {}: {}", self.log_prefix, outpoint, e);
3118 }
3119 }
3120 }
3121 }
3122 }
3123
3124 info!("{} Sync completed!", self.log_prefix);
3125 Ok(())
3126 }
3127
3128 /// Request a graph or resume its durable record. Saved holds are safe
3129 /// to pass because the next run resumes keys independently of pagination.
3130 /// Empty deadline retries hold the cursor until the stall threshold.
3131 /// All resumed graphs share one allowance; fresh graphs retain theirs.
3132 async fn ingest_utxo(&self, utxo: &GASPOutput, outpoint: &str) -> Result<Ingested, GASPError> {
3133 if self.graph_budget.is_some() {
3134 crate::meter::root_scope(self.ingest_utxo_inner(utxo, outpoint)).await
3135 } else {
3136 self.ingest_utxo_inner(utxo, outpoint).await
3137 }
3138 }
3139
3140 async fn ingest_utxo_inner(
3141 &self,
3142 utxo: &GASPOutput,
3143 outpoint: &str,
3144 ) -> Result<Ingested, GASPError> {
3145 self.last_fault.set(None);
3146 self.resume_idle.set(None);
3147 self.pass_saved.set(false);
3148 if self.expired.borrow().contains(outpoint) {
3149 if self.deferred.borrow().contains(outpoint) {
3150 let record = self.storage.get_deferred_graph(outpoint).await?;
3151 if self.sweep_expired_record(record.as_ref()).await? {
3152 return Ok(Ingested::Expired);
3153 }
3154 }
3155 return self.refuse_expired(outpoint).await;
3156 }
3157 debug!("{} Requesting node for {}", self.log_prefix, outpoint);
3158 self.graphs_attempted.set(self.graphs_attempted.get() + 1);
3159 let Some(budget) = &self.graph_budget else {
3160 *self.walk.borrow_mut() =
3161 Some(Self::fresh_walk(utxo.score as u64, outpoint, PassCap::NONE));
3162 crate::meter::walk_output();
3163 let walked = self.walk_graph(None).await;
3164 if matches!(walked, Ok(WalkEnd::Expired)) {
3165 return self.refuse_expired(outpoint).await;
3166 }
3167 let walk = self.walk.borrow_mut().take();
3168 walked?;
3169 self.complete_graph(&Self::graph_id_of(walk.as_ref(), outpoint))
3170 .await?;
3171 return Ok(Ingested::Completed);
3172 };
3173
3174 if !self.deferred.borrow_mut().remove(outpoint) {
3175 let mut deadline = latched((budget.deadline)());
3176 return self
3177 .ingest_budgeted(utxo, outpoint, &mut deadline, budget.pass(), false)
3178 .await;
3179 }
3180 let mut deadline = self
3181 .resume_deadline
3182 .borrow_mut()
3183 .take()
3184 .unwrap_or_else(|| latched((budget.deadline)()));
3185 let cap = self.resume_left.get();
3186 if cap.spent() || Self::due(&mut deadline).await {
3187 *self.resume_deadline.borrow_mut() = Some(deadline);
3188 let record = self.storage.get_deferred_graph(outpoint).await?;
3189 self.read_record(record.as_ref());
3190 if self.sweep_expired_record(record.as_ref()).await? {
3191 return Ok(Ingested::Expired);
3192 }
3193 if record
3194 .as_ref()
3195 .is_some_and(|r| r.waits_idle(Self::now_secs()))
3196 {
3197 return Ok(self.wait_idle(outpoint));
3198 }
3199 // An unanchorable root inside its backoff would not be walked
3200 // anyway: not held back, and no attempt (engine 0.1.11).
3201 if record.as_ref().is_some_and(|r| {
3202 r.is_dead() || (r.is_unanchorable() && Self::now_secs() < r.retry_at)
3203 }) {
3204 self.graphs_attempted
3205 .set(self.graphs_attempted.get().saturating_sub(1));
3206 return Ok(Ingested::Unanchorable);
3207 }
3208 self.stats.borrow_mut().held_back += 1;
3209 info!(
3210 "{} Deferred graph {} held back: the resumes of this pass spent their budget (bsv-low #555)",
3211 self.log_prefix, outpoint
3212 );
3213 let Some(mut record) = record else {
3214 return Ok(Ingested::Retry);
3215 };
3216 if record.reason == "retry:deadline" {
3217 return Ok(Ingested::Retry);
3218 }
3219 // A fault streak not yet at its threshold holds the cursor; one
3220 // not walked this run is not a faulted run (engine 0.1.9).
3221 if record.is_struck() && !record.cursor_passable {
3222 return Ok(Ingested::Retry);
3223 }
3224 if !record.cursor_passable {
3225 record.cursor_passable = true;
3226 if self.storage.save_deferred_graph(&record).await? != DeferredGraphSave::Saved {
3227 return Ok(Ingested::Retry);
3228 }
3229 }
3230 return Ok(Ingested::HeldBack);
3231 }
3232 let before = (
3233 self.calls_made.get(),
3234 self.bytes_fetched.get(),
3235 self.nodes_appended.get(),
3236 );
3237 let ingested = self
3238 .ingest_budgeted(utxo, outpoint, &mut deadline, cap, true)
3239 .await;
3240 // Engine 0.1.10: a held record's resume that asked the peer,
3241 // appended no node and did not finish counts one more idle resume.
3242 // Its own save counted it; any other ending counts it here (engine
3243 // 0.1.11): a drop that saved the record again as a fresh obligation
3244 // (`drop_record`) or an error that left it as it was. What the pass
3245 // KEPT is read from the record, not from the nodes it appended: on
3246 // 0.1.10 a root appended and then thrown away with the drop (fact 3
3247 // of the 0.1.11 brief: the courier exhausted on its parent) read as
3248 // progress, and the count never fired. A record a strike counted is
3249 // left to the strike.
3250 if let Some((prior, held)) = self.resume_idle.take() {
3251 if self.calls_made.get() > before.0
3252 && !self.pass_saved.get()
3253 && !matches!(ingested, Ok(Ingested::Completed | Ingested::Expired))
3254 {
3255 self.idle_resume(outpoint, prior, held).await;
3256 }
3257 }
3258 let narrowed = |spent: u64| u32::try_from(spent).unwrap_or(u32::MAX);
3259 self.resume_left.set(PassCap {
3260 calls: cap
3261 .calls
3262 .saturating_sub(narrowed(self.calls_made.get() - before.0)),
3263 bytes: cap
3264 .bytes
3265 .saturating_sub(self.bytes_fetched.get() - before.1),
3266 nodes: cap
3267 .nodes
3268 .saturating_sub(narrowed(self.nodes_appended.get() - before.2)),
3269 });
3270 *self.resume_deadline.borrow_mut() = Some(deadline);
3271 ingested
3272 }
3273
3274 /// A held record not resumed: it waits out the long backoff of its idle
3275 /// resumes (engine 0.1.10). Not asked of the peer: not an attempt for
3276 /// the yieldless streak, as an unanchorable root inside its backoff.
3277 fn wait_idle(&self, outpoint: &str) -> Ingested {
3278 self.graphs_attempted
3279 .set(self.graphs_attempted.get().saturating_sub(1));
3280 self.stats.borrow_mut().waiting += 1;
3281 debug!(
3282 "{} Deferred graph {} waits out its idle backoff (engine 0.1.10)",
3283 self.log_prefix, outpoint
3284 );
3285 Ingested::Waiting
3286 }
3287
3288 /// Count one more idle resume on the held record of `outpoint` after a
3289 /// pass that did not save it (engine 0.1.10): `prior` is what it counted
3290 /// when its pass read it, so a record the pass dropped and saved again as
3291 /// a fresh obligation (`drop_record`) counts on from there. At
3292 /// [`IDLE_RESUMES_BEFORE_BACKOFF`] it goes to the long backoff. A record
3293 /// gone, a fault record, one that holds the cursor, or one holding more
3294 /// nodes than the `held` its pass read (it kept progress) is left alone.
3295 async fn idle_resume(&self, outpoint: &str, prior: u32, held: usize) {
3296 let Ok(Some(mut record)) = self.storage.get_deferred_graph(outpoint).await else {
3297 return;
3298 };
3299 if record.is_struck() || !record.cursor_passable || record.nodes.len() > held {
3300 return;
3301 }
3302 record.idle_resumes = prior.saturating_add(1);
3303 let backoff = record.idle_resumes >= IDLE_RESUMES_BEFORE_BACKOFF;
3304 if backoff {
3305 record.retry_at = Self::now_secs() + IDLE_RESUME_RETRY_SECS;
3306 }
3307 let saved = self.storage.save_deferred_graph(&record).await;
3308 if let Ok(DeferredGraphSave::Saved) = saved {
3309 self.stats.borrow_mut().idle_counted += 1;
3310 }
3311 match saved {
3312 Ok(DeferredGraphSave::Saved) if backoff => {
3313 self.stats.borrow_mut().backed_off += 1;
3314 info!(
3315 "{} Deferred graph {} resumed {} passes in a row with no node appended: not resumed for {} s (engine 0.1.10)",
3316 self.log_prefix, outpoint, record.idle_resumes, IDLE_RESUME_RETRY_SECS
3317 );
3318 }
3319 Ok(_) => {}
3320 Err(e) => warn!(
3321 "{} Could not count an idle resume of deferred graph {}: {}",
3322 self.log_prefix, outpoint, e
3323 ),
3324 }
3325 }
3326
3327 /// Whether `deadline` has fallen due (polled once, with the task's own
3328 /// waker).
3329 async fn due(deadline: &mut crate::engine::SleepFuture) -> bool {
3330 std::future::poll_fn(|cx| std::task::Poll::Ready(deadline.as_mut().poll(cx).is_ready()))
3331 .await
3332 }
3333
3334 /// A walk from the root `outpoint`, nothing fetched yet.
3335 fn fresh_walk(score: u64, outpoint: &str, cap: PassCap) -> Walk {
3336 Walk {
3337 record: DeferredGraph {
3338 peer: String::new(),
3339 topic: String::new(),
3340 outpoint: outpoint.to_string(),
3341 score,
3342 nodes: Vec::new(),
3343 pending: vec![PendingInput {
3344 outpoint: outpoint.to_string(),
3345 graph_id: outpoint.to_string(),
3346 metadata: true,
3347 spent_by: None,
3348 parent_proven: false,
3349 }],
3350 calls: 0,
3351 passes: 0,
3352 reason: String::new(),
3353 configured: false,
3354 idle_faults: 0,
3355 cursor_passable: false,
3356 faults: 0,
3357 retry_at: 0,
3358 idle_resumes: 0,
3359 unanchorable_retries: 0,
3360 expiry: None,
3361 },
3362 seen: std::collections::HashSet::new(),
3363 calls_this_pass: 0,
3364 bytes_this_pass: 0,
3365 nodes_this_pass: 0,
3366 resumed: false,
3367 cap,
3368 nodes_at_start: 0,
3369 counted: false,
3370 gone_on: false,
3371 half: None,
3372 struck: None,
3373 }
3374 }
3375
3376 /// One pass of a graph's walk under the per-graph budget: `deadline`,
3377 /// and `cap` (calls, bytes, nodes). `from_record`: a record of it is held (its key was
3378 /// loaded); it is read now.
3379 async fn ingest_budgeted(
3380 &self,
3381 utxo: &GASPOutput,
3382 outpoint: &str,
3383 deadline: &mut crate::engine::SleepFuture,
3384 cap: PassCap,
3385 from_record: bool,
3386 ) -> Result<Ingested, GASPError> {
3387 let score = utxo.score as u64;
3388 // A read fault fails the UTXO and keeps the record for the next sync.
3389 let mut record = if from_record {
3390 let record = self.storage.get_deferred_graph(outpoint).await?;
3391 self.read_record(record.as_ref());
3392 if record.is_none() {
3393 // Deleted since the keys were read (the worker's sweep).
3394 self.held_records
3395 .set(self.held_records.get().saturating_sub(1));
3396 }
3397 record
3398 } else {
3399 None
3400 };
3401 if self.sweep_expired_record(record.as_ref()).await? {
3402 return Ok(Ingested::Expired);
3403 }
3404 if record
3405 .as_ref()
3406 .is_some_and(|r| (r.is_unanchorable() || r.is_dead()) && r.score != score)
3407 {
3408 self.storage.delete_deferred_graph(outpoint).await?;
3409 self.forget_record(outpoint);
3410 record = None;
3411 }
3412 if let Some(record) = record.as_ref().filter(|r| r.is_struck()) {
3413 return self
3414 .rewalk_struck(utxo, outpoint, record.clone(), deadline, cap)
3415 .await;
3416 }
3417 if let Some(record) = &record {
3418 if let [root, ..] = record.nodes.as_slice() {
3419 if self.root_expired(&root.node, outpoint)? {
3420 self.deferred.borrow_mut().insert(outpoint.to_string());
3421 return self.refuse_expired(outpoint).await;
3422 }
3423 }
3424 // Engine 0.1.10: a held record past its idle resumes waits out
3425 // the long backoff (an expired root it holds was refused above,
3426 // with no request); else its pass counts on from what it holds.
3427 if record.cursor_passable {
3428 if record.waits_idle(Self::now_secs()) {
3429 return Ok(self.wait_idle(outpoint));
3430 }
3431 self.resume_idle
3432 .set(Some((record.idle_resumes, record.nodes.len())));
3433 }
3434 }
3435 let walk = match record {
3436 None => {
3437 let mut walk = Self::fresh_walk(score, outpoint, cap);
3438 // D2-L1: the half-budget clock starts with the walk (polled
3439 // once now, so a timer that starts on its first poll runs).
3440 if let Some(half) = self
3441 .graph_budget
3442 .as_ref()
3443 .and_then(|b| b.half_deadline.as_ref())
3444 {
3445 let mut half = latched(half());
3446 let _ = Self::due(&mut half).await;
3447 walk.half = Some(half);
3448 }
3449 walk
3450 }
3451 Some(record) if record.passes >= DEFERRED_GRAPH_MAX_PASSES => {
3452 let passes = record.passes;
3453 self.drop_record(outpoint, DropReason::MaxPasses).await;
3454 return Err(GASPError::Other(format!(
3455 "deferred graph {outpoint} dropped after {passes} passes (bsv-low #555)"
3456 )));
3457 }
3458 Some(record) => {
3459 self.stats.borrow_mut().resumed += 1;
3460 info!(
3461 "{} Resuming deferred graph {} (bsv-low #555): {} nodes, {} pending, {} calls over {} passes",
3462 self.log_prefix,
3463 outpoint,
3464 record.nodes.len(),
3465 record.pending.len(),
3466 record.calls,
3467 record.passes
3468 );
3469 Walk {
3470 seen: record
3471 .nodes
3472 .iter()
3473 .filter_map(|w| Self::node_id(&w.node))
3474 .collect(),
3475 nodes_at_start: record.nodes.len(),
3476 record,
3477 calls_this_pass: 0,
3478 bytes_this_pass: 0,
3479 nodes_this_pass: 0,
3480 resumed: true,
3481 cap,
3482 counted: false,
3483 gone_on: false,
3484 half: None,
3485 struck: None,
3486 }
3487 }
3488 };
3489 let resumed = walk.resumed;
3490 let root_unproven = walk
3491 .record
3492 .nodes
3493 .first()
3494 .is_some_and(|w| w.node.proof.is_none());
3495 crate::meter::walk_output();
3496 *self.walk.borrow_mut() = Some(walk);
3497
3498 let ended = async {
3499 if resumed && root_unproven {
3500 // The root is asked again (one call): once its block lands
3501 // the peer serves it PROVEN, and the walk from it is shorter
3502 // than the unproven ancestry the record is still walking.
3503 let root = PendingInput {
3504 outpoint: outpoint.to_string(),
3505 graph_id: outpoint.to_string(),
3506 metadata: true,
3507 spent_by: None,
3508 parent_proven: false,
3509 };
3510 match race_or_deadline(self.fetch_root(&root), deadline.as_mut()).await {
3511 None => return Ok(WalkEnd::Deferred("time")),
3512 Some(Ok(None)) => return Ok(WalkEnd::Expired),
3513 Some(Ok(Some(node))) if node.proof.is_some() => {
3514 // The walk is fresh, the PASS is not: what it spent
3515 // of every counted limb goes with it (the re-ask
3516 // appends nothing, so the nodes are 0 today; carried
3517 // all the same, so a limb is never forgotten here).
3518 let (carried, carried_bytes, carried_nodes) =
3519 self.walk.borrow().as_ref().map_or((0, 0, 0), |w| {
3520 (w.calls_this_pass, w.bytes_this_pass, w.nodes_this_pass)
3521 });
3522 let reserved = self
3523 .walk
3524 .borrow()
3525 .as_ref()
3526 .is_some_and(|w| w.record.cursor_passable);
3527 self.drop_record(outpoint, DropReason::RootProven).await;
3528 let mut restarted = Self::fresh_walk(score, outpoint, cap);
3529 restarted.resumed = reserved;
3530 restarted.calls_this_pass = carried;
3531 restarted.bytes_this_pass = carried_bytes;
3532 restarted.nodes_this_pass = carried_nodes;
3533 restarted.record.calls = u64::from(carried);
3534 *self.walk.borrow_mut() = Some(restarted);
3535 }
3536 // Unchanged, or not answered: the record goes on.
3537 Some(_) => {}
3538 }
3539 }
3540 self.append_walked().await?;
3541 self.walk_graph(Some(&mut *deadline)).await
3542 }
3543 .await;
3544 self.finish_pass(outpoint, ended, deadline).await
3545 }
3546
3547 /// Append the walk's fetched nodes to the graph (a resume, or a walk
3548 /// that goes on after its graph was discarded).
3549 async fn append_walked(&self) -> Result<(), GASPError> {
3550 let nodes: Vec<WalkedNode> = self
3551 .walk
3552 .borrow()
3553 .as_ref()
3554 .map(|w| w.record.nodes.clone())
3555 .unwrap_or_default();
3556 for walked in &nodes {
3557 self.storage
3558 .append_to_graph(&walked.node, walked.spent_by.as_deref())
3559 .await?;
3560 }
3561 Ok(())
3562 }
3563
3564 /// The end of a budgeted pass: complete the graph, defer it, or fail
3565 /// its UTXO. A call/size cut whose record cannot be kept may continue
3566 /// within the same root deadline (L4). Time and courier limits stop it
3567 /// even when the storage refuses the save; the cursor retains its gap.
3568 async fn finish_pass(
3569 &self,
3570 outpoint: &str,
3571 mut ended: Result<WalkEnd, GASPError>,
3572 deadline: &mut crate::engine::SleepFuture,
3573 ) -> Result<Ingested, GASPError> {
3574 loop {
3575 let graph_id = Self::graph_id_of(self.walk.borrow().as_ref(), outpoint);
3576 // Whether a record of this graph is still held: not after a
3577 // `root_proven` restart, whose walk is fresh.
3578 let resumed = self.walk.borrow().as_ref().is_some_and(|w| w.resumed);
3579 let (reason, completed) = match ended {
3580 Ok(WalkEnd::Expired) => return self.refuse_expired(outpoint).await,
3581 Ok(WalkEnd::Done) => match self.complete_graph(&graph_id).await {
3582 Ok(true) => return Ok(Ingested::Completed),
3583 Ok(false) => {
3584 // Refused by the anchor check: a verdict, the cursor
3585 // moves, nothing to resume. A root's fault record
3586 // goes with it (engine 0.1.9).
3587 let struck = self
3588 .walk
3589 .borrow_mut()
3590 .take()
3591 .is_some_and(|w| w.struck.is_some());
3592 if resumed || struck {
3593 if struck {
3594 self.unanchorable_cleared
3595 .set(self.unanchorable_cleared.get() + 1);
3596 }
3597 self.drop_record(outpoint, DropReason::Refused).await;
3598 }
3599 return Ok(Ingested::Completed);
3600 }
3601 // `complete_graph` discarded the graph already.
3602 Err(GASPError::AnchorUnavailable(_)) => ("anchor_unavailable", true),
3603 // The same hold, for a walk past the budget (E592 fold).
3604 Err(GASPError::AnchorWalkHeld(_)) => ("anchor_walk_held", true),
3605 // A finalize fault: the graph is still in hand.
3606 Err(_) => {
3607 let _ = self.storage.discard_graph(&graph_id).await;
3608 ("fault", true)
3609 }
3610 },
3611 Ok(WalkEnd::Deferred(reason)) => (reason, false),
3612 Err(e @ (GASPError::NodeNotFound(_) | GASPError::DefinitiveNotFound(_))) => {
3613 let _ = self.storage.discard_graph(&graph_id).await;
3614 let walk = self.walk.borrow_mut().take();
3615 if resumed {
3616 // Engine 0.1.11: a fault of the graph is a strike on
3617 // the record's own slot, as for a fresh root.
3618 if let Some(struck) = self.strike_resumed(outpoint, walk.as_ref(), &e).await
3619 {
3620 return if struck.passable() {
3621 Ok(Ingested::Unanchorable)
3622 } else {
3623 Err(e)
3624 };
3625 }
3626 self.drop_record(outpoint, DropReason::NotHeld).await;
3627 }
3628 return Err(e);
3629 }
3630 // A walk ERROR on a FRESH walk keeps no record (the delta
3631 // fold's D-M1): a fault is not a budget cut, and before #555
3632 // it failed the UTXO. A record then costs a peer a budget
3633 // cut (`calls`, `time`, `peer_deadline`), never a quick 5xx:
3634 // a stranger who answered each input with a 503 kept a
3635 // one-node record per UTXO, 16 per (peer, topic) in one tick
3636 // with no time spent, and filled the worker's ceiling. A
3637 // RESUMED walk keeps its record (its progress was paid for).
3638 //
3639 // Unless it PAID (the delta-2 fold's D2-L1): a fresh walk
3640 // that faults after half its per-graph calls or half its
3641 // time keeps its record as a budget cut would, so an honest
3642 // flaky peer's deep graph is not walked from its root on
3643 // every tick ((1-p)^100 a clean pass at a fault rate p).
3644 Err(e) if !resumed && !self.walk_paid().await => {
3645 warn!(
3646 "{} Walk of {} faulted: no record, the UTXO fails as before #555: {}",
3647 self.log_prefix, outpoint, e
3648 );
3649 let _ = self.storage.discard_graph(&graph_id).await;
3650 self.walk.borrow_mut().take();
3651 return Err(e);
3652 }
3653 Err(e) => {
3654 // D2-M2: a RESUMED pass that faulted having appended
3655 // nothing is idle; at DEFERRED_GRAPH_MAX_IDLE_FAULTS in a
3656 // row the record goes and the UTXO fails.
3657 let idle = {
3658 let mut guard = self.walk.borrow_mut();
3659 guard.as_mut().map_or(0, |w| {
3660 if w.resumed && w.record.nodes.len() <= w.nodes_at_start {
3661 w.record.idle_faults += 1;
3662 }
3663 w.record.idle_faults
3664 })
3665 };
3666 if idle >= DEFERRED_GRAPH_MAX_IDLE_FAULTS {
3667 warn!(
3668 "{} Walk of {} faulted with nothing appended {} passes in a row: dropped: {}",
3669 self.log_prefix, outpoint, idle, e
3670 );
3671 let _ = self.storage.discard_graph(&graph_id).await;
3672 let walk = self.walk.borrow_mut().take();
3673 // Engine 0.1.11: dropped by a fault of the graph, a
3674 // strike on the record's own slot.
3675 if let Some(struck) = self.strike_resumed(outpoint, walk.as_ref(), &e).await
3676 {
3677 return if struck.passable() {
3678 Ok(Ingested::Unanchorable)
3679 } else {
3680 Err(e)
3681 };
3682 }
3683 self.drop_record(outpoint, DropReason::IdleFaults).await;
3684 return Err(e);
3685 }
3686 warn!(
3687 "{} Walk of {} faulted, deferred with its progress: {}",
3688 self.log_prefix, outpoint, e
3689 );
3690 let _ = self.storage.discard_graph(&graph_id).await;
3691 ("fault", false)
3692 }
3693 };
3694 // Call and size cuts may go on if no record fits (L4), with
3695 // the original deadline still active. Time and courier cuts
3696 // stop this root even if its save is refused.
3697 let budget_cut = matches!(reason, "calls" | "bytes" | "nodes");
3698 let (why, mut walk) = match self.save_walk(reason, completed).await {
3699 Saved::Retry => {
3700 let _ = self.storage.discard_graph(&graph_id).await;
3701 return Ok(Ingested::Retry);
3702 }
3703 Saved::Yes => {
3704 let _ = self.storage.discard_graph(&graph_id).await;
3705 return Ok(Ingested::Deferred);
3706 }
3707 Saved::WentOn => {
3708 let _ = self.storage.discard_graph(&graph_id).await;
3709 return Err(GASPError::Other(format!(
3710 "graph {outpoint} went on and did not complete (bsv-low #555)"
3711 )));
3712 }
3713 // A store FAULT at a deferral goes on as the other bounds do
3714 // (the delta fold's D-L2; counted `store_fault` by
3715 // `save_walk`): it failed the UTXO, a transient D1 fault
3716 // failing a graph the per-peer budget would have completed.
3717 Saved::Fault(e, walk) => {
3718 if !budget_cut {
3719 let _ = self.storage.discard_graph(&graph_id).await;
3720 return Err(e);
3721 }
3722 (DropReason::StoreFault, walk)
3723 }
3724 // No room for a RESUMED walk (bsv-low #585): its held record
3725 // stays, so what this pass adds is lost and nothing else. A
3726 // budget cut goes on as the other bounds do; any other end
3727 // fails the UTXO and the next pass resumes from the record.
3728 Saved::Ceiling(walk) => {
3729 if !budget_cut {
3730 let _ = self.storage.discard_graph(&graph_id).await;
3731 return Err(GASPError::Other(format!(
3732 "deferred graph {outpoint} found no room, its record kept (bsv-low #585)"
3733 )));
3734 }
3735 (DropReason::TooMany, walk)
3736 }
3737 Saved::Unkept(why, mut walk) => {
3738 self.unkept(why, &mut walk).await;
3739 if !(budget_cut && why == DropReason::TooMany) {
3740 let _ = self.storage.discard_graph(&graph_id).await;
3741 return Err(GASPError::Other(format!(
3742 "deferred graph {outpoint} not kept: {} (bsv-low #555)",
3743 why.as_str()
3744 )));
3745 }
3746 (why, walk)
3747 }
3748 };
3749 info!(
3750 "{} Graph {} cannot be kept ({}): its walk goes on with the root deadline (bsv-low #555)",
3751 self.log_prefix,
3752 outpoint,
3753 why.as_str()
3754 );
3755 walk.cap = PassCap::NONE;
3756 walk.gone_on = true;
3757 *self.walk.borrow_mut() = Some(*walk);
3758 // The graph in hand was never discarded: walk on.
3759 ended = self.walk_graph(Some(&mut *deadline)).await;
3760 }
3761 }
3762
3763 /// After a COMPLETED graph's hook (bsv-low #555): a graph that landed
3764 /// deletes its record (a resumed one converged); one whose finalize did
3765 /// not land keeps its walk as a record with nothing pending, so the next
3766 /// pass completes it again without walking it again. No-op without a
3767 /// per-graph budget.
3768 async fn settle_completed(&self, landed: bool) {
3769 if self.graph_budget.is_none() {
3770 self.walk.borrow_mut().take();
3771 return;
3772 }
3773 if landed {
3774 let Some(walk) = self.walk.borrow_mut().take() else {
3775 return;
3776 };
3777 if walk.struck.is_some() {
3778 // A root walked again after faulted runs landed (engine
3779 // 0.1.9): admitted as any root, its record cleared.
3780 if let Err(e) = self
3781 .storage
3782 .delete_deferred_graph(&walk.record.outpoint)
3783 .await
3784 {
3785 warn!(
3786 "{} Could not delete the fault record of landed root {}: {}",
3787 self.log_prefix, walk.record.outpoint, e
3788 );
3789 }
3790 self.forget_record(&walk.record.outpoint);
3791 self.unanchorable_cleared
3792 .set(self.unanchorable_cleared.get() + 1);
3793 info!(
3794 "{} Root {} LANDED after {} faulted runs: its record cleared (engine 0.1.9)",
3795 self.log_prefix,
3796 walk.record.outpoint,
3797 walk.struck.as_ref().map_or(0, |r| r.faults)
3798 );
3799 } else if walk.resumed {
3800 if let Err(e) = self
3801 .storage
3802 .delete_deferred_graph(&walk.record.outpoint)
3803 .await
3804 {
3805 warn!(
3806 "{} Could not delete the record of converged graph {}: {}",
3807 self.log_prefix, walk.record.outpoint, e
3808 );
3809 }
3810 self.forget_record(&walk.record.outpoint);
3811 self.stats.borrow_mut().converged += 1;
3812 self.resumed_with_progress
3813 .set(self.resumed_with_progress.get() + 1);
3814 info!(
3815 "{} Deferred graph {} CONVERGED after {} passes, {} calls, {} nodes (bsv-low #555)",
3816 self.log_prefix,
3817 walk.record.outpoint,
3818 walk.record.passes + 1,
3819 walk.record.calls,
3820 walk.record.nodes.len()
3821 );
3822 }
3823 } else if let Saved::Unkept(why, mut walk) = self.save_walk("not_landed", true).await {
3824 self.unkept(why, &mut walk).await;
3825 }
3826 }
3827
3828 /// Save the walk in hand as its graph's record (bsv-low #555), counting
3829 /// a deferral; or hand it back UNKEPT with the reason (past a bound, or
3830 /// holding no node), or answer the storage's fault with the walk (a call
3831 /// or size cut may go on within its deadline, D-L2; any other fails its
3832 /// UTXO). A walk that already went on is neither saved nor counted again
3833 /// (D-L1). Counts the pass progressed or stalled (the lens fold's H1),
3834 /// once per pass.
3835 async fn save_walk(&self, reason: &'static str, completed: bool) -> Saved {
3836 let Some(mut walk) = self.walk.borrow_mut().take() else {
3837 // Nothing in hand: nothing to save.
3838 return Saved::Yes;
3839 };
3840 if walk.gone_on {
3841 // Its drop was counted when it went on (D-L1).
3842 return Saved::WentOn;
3843 }
3844 if !walk.counted {
3845 walk.counted = true;
3846 let mut stats = self.stats.borrow_mut();
3847 if completed || walk.record.nodes.len() > walk.nodes_at_start {
3848 stats.progressed += 1;
3849 } else {
3850 stats.stalled += 1;
3851 }
3852 }
3853 let was_held = walk.record.cursor_passable;
3854 let deadline_cut = matches!(reason, "time" | "peer_deadline");
3855 let deadline_streak = was_held || walk.record.reason == "retry:deadline";
3856 if walk.record.nodes.is_empty() && !deadline_streak {
3857 walk.record.passes = 0;
3858 }
3859 walk.record.passes += 1;
3860 // Only the graph's OWN deadline earns the stall allowance. The
3861 // per-peer deadline ends the slice, not the graph: what it cut is
3862 // held at once and the cursor passes it.
3863 let retry = walk.record.nodes.is_empty()
3864 && reason == "time"
3865 && !was_held
3866 && walk.record.passes < DEADLINE_STALL_DEFERRAL_RUNS;
3867 walk.record.cursor_passable = !retry;
3868 walk.record.reason = if retry {
3869 "retry:deadline".into()
3870 } else {
3871 reason.into()
3872 };
3873 if walk.record.nodes.len() > walk.nodes_at_start {
3874 walk.record.idle_faults = 0;
3875 // A pass that appends a node resets the idle resumes (0.1.10).
3876 walk.record.idle_resumes = 0;
3877 walk.record.retry_at = 0;
3878 }
3879 // Engine 0.1.10: a RESUMED pass of a held record that asked the peer
3880 // and appended nothing counts one idle resume in this save; at the
3881 // bound the record waits out the long backoff.
3882 let idle_now = walk.resumed
3883 && was_held
3884 && walk.struck.is_none()
3885 && !completed
3886 && walk.calls_this_pass > 0
3887 && walk.record.nodes.len() <= walk.nodes_at_start;
3888 let backing_off = if idle_now {
3889 walk.record.idle_resumes = walk.record.idle_resumes.saturating_add(1);
3890 let due = walk.record.idle_resumes >= IDLE_RESUMES_BEFORE_BACKOFF;
3891 if due {
3892 walk.record.retry_at = Self::now_secs() + IDLE_RESUME_RETRY_SECS;
3893 }
3894 due
3895 } else {
3896 false
3897 };
3898 // Empty walks qualify only after repeated deadline cuts. The small
3899 // retry record uses the same per-peer and global storage ceilings.
3900 // A quick fresh fault still earns no record.
3901 if walk.record.nodes.is_empty() && !deadline_cut && !was_held {
3902 return Saved::Unkept(DropReason::NoProgress, Box::new(walk));
3903 }
3904 // A resumed walk holds its record's deferred slot. A root walked
3905 // again after faulted runs holds a FAULT record (engine 0.1.10): the
3906 // hold its walk earned replaces it only with room among the deferred
3907 // slots, else the walk goes on as any walk past the bound (L4) and
3908 // its fault record stays as it was.
3909 if !walk.resumed && self.held_records.get() >= DEFERRED_GRAPHS_PER_PEER_TOPIC {
3910 return Saved::Unkept(DropReason::TooMany, Box::new(walk));
3911 }
3912 // No bound on a record's size (bsv-low #585): the figure is logged
3913 // and is the storage's to budget.
3914 let bytes = walk.record.byte_size();
3915 let outpoint = walk.record.outpoint.clone();
3916 match self.storage.save_deferred_graph(&walk.record).await {
3917 Ok(DeferredGraphSave::Saved) => {
3918 if walk.resumed {
3919 self.pass_saved.set(true);
3920 }
3921 if idle_now {
3922 self.stats.borrow_mut().idle_counted += 1;
3923 }
3924 if !walk.resumed {
3925 // A fault record replaced is a fault record no more.
3926 self.fault_records.borrow_mut().remove(&outpoint);
3927 self.held_records.set(self.held_records.get() + 1);
3928 if !retry && !walk.record.nodes.is_empty() {
3929 self.retained.set(self.retained.get() + 1);
3930 }
3931 } else if walk.record.nodes.len() > walk.nodes_at_start {
3932 self.resumed_with_progress
3933 .set(self.resumed_with_progress.get() + 1);
3934 }
3935 if !retry {
3936 self.stats.borrow_mut().deferred += 1;
3937 }
3938 if backing_off {
3939 self.stats.borrow_mut().backed_off += 1;
3940 info!(
3941 "{} Deferred graph {} resumed {} passes in a row with no node appended: not resumed for {} s (engine 0.1.10)",
3942 self.log_prefix, outpoint, walk.record.idle_resumes, IDLE_RESUME_RETRY_SECS
3943 );
3944 }
3945 info!(
3946 "{} DEFERRED graph {} ({}): {} nodes, {} pending, {} calls ({} this pass), pass {}, {} bytes (bsv-low #555)",
3947 self.log_prefix,
3948 outpoint,
3949 reason,
3950 walk.record.nodes.len(),
3951 walk.record.pending.len(),
3952 walk.record.calls,
3953 walk.calls_this_pass,
3954 walk.record.passes,
3955 bytes
3956 );
3957 if retry {
3958 Saved::Retry
3959 } else {
3960 Saved::Yes
3961 }
3962 }
3963 // The storage's ceiling is a budget of room, not a verdict on
3964 // the graph (bsv-low #585): a RESUMED walk keeps the record it
3965 // holds (it was deleted, and the graph walked from its root at
3966 // the next pass); a fresh walk has none to keep.
3967 Ok(DeferredGraphSave::AtCeiling) if walk.resumed => {
3968 self.note_dropped(&outpoint, DropReason::TooMany);
3969 Saved::Ceiling(Box::new(walk))
3970 }
3971 Ok(DeferredGraphSave::AtCeiling) => Saved::Unkept(DropReason::TooMany, Box::new(walk)),
3972 // The save is lost and counted `store_fault`. A RESUMED walk's
3973 // held record is KEPT (the delta-2 lens's D2-N2: it was deleted,
3974 // and one transient D1 fault threw away every earlier pass of a
3975 // walk the per-peer budget did not then finish): the next pass
3976 // resumes from it, losing this pass only; a walk that goes on
3977 // and lands deletes it as a converged one.
3978 Err(e) => {
3979 self.note_dropped(&outpoint, DropReason::StoreFault);
3980 Saved::Fault(e, Box::new(walk))
3981 }
3982 }
3983 }
3984
3985 /// A walk the save did not keep: reset its retained obligation or count
3986 /// the drop. A fresh walk that fetched nothing is only logged.
3987 async fn unkept(&self, why: DropReason, walk: &mut Walk) {
3988 let outpoint = walk.record.outpoint.clone();
3989 if walk.resumed {
3990 self.drop_record(&outpoint, why).await;
3991 walk.resumed = false;
3992 } else if why == DropReason::NoProgress {
3993 warn!(
3994 "{} Walk of {} fetched nothing: not kept, the UTXO fails (bsv-low #555)",
3995 self.log_prefix, outpoint
3996 );
3997 } else {
3998 self.note_dropped(&outpoint, why);
3999 }
4000 }
4001
4002 /// Whether the walk in hand has PAID for a record (the delta-2 fold's
4003 /// D2-L1): half its per-graph calls made in this pass, or its half
4004 /// deadline fallen due. A walk that holds no node never has.
4005 async fn walk_paid(&self) -> bool {
4006 let Some(budget) = &self.graph_budget else {
4007 return false;
4008 };
4009 let mut half = {
4010 let mut guard = self.walk.borrow_mut();
4011 let Some(walk) = guard.as_mut() else {
4012 return false;
4013 };
4014 if walk.record.nodes.is_empty() {
4015 return false;
4016 }
4017 if u64::from(walk.calls_this_pass) * 2 >= u64::from(budget.max_calls) {
4018 return true;
4019 }
4020 match walk.half.take() {
4021 Some(half) => half,
4022 None => return false,
4023 }
4024 };
4025 let paid = Self::due(&mut half).await;
4026 if let Some(walk) = self.walk.borrow_mut().as_mut() {
4027 walk.half = Some(half);
4028 }
4029 paid
4030 }
4031
4032 /// Drop the partial walk, preserving a retry obligation if the cursor may have passed.
4033 async fn drop_record(&self, outpoint: &str, reason: DropReason) {
4034 if !matches!(reason, DropReason::Held | DropReason::Refused) {
4035 let Ok(record) = self.storage.get_deferred_graph(outpoint).await else {
4036 return;
4037 };
4038 if let Some(record) = record {
4039 if record.cursor_passable {
4040 let mut retry = Self::fresh_walk(record.score, outpoint, PassCap::NONE).record;
4041 retry.reason = "retry".into();
4042 retry.cursor_passable = true;
4043 let _ = self.storage.save_deferred_graph(&retry).await;
4044 self.note_dropped(outpoint, reason);
4045 return;
4046 }
4047 }
4048 }
4049 if let Err(e) = self.storage.delete_deferred_graph(outpoint).await {
4050 warn!(
4051 "{} Could not delete the record of deferred graph {}: {}",
4052 self.log_prefix, outpoint, e
4053 );
4054 }
4055 self.forget_record(outpoint);
4056 self.note_dropped(outpoint, reason);
4057 }
4058
4059 fn note_dropped(&self, outpoint: &str, reason: DropReason) {
4060 warn!(
4061 "{} Deferred graph {} DROPPED ({}) (bsv-low #555)",
4062 self.log_prefix,
4063 outpoint,
4064 reason.as_str()
4065 );
4066 self.stats.borrow_mut().dropped.push(DroppedGraph {
4067 outpoint: outpoint.to_string(),
4068 reason,
4069 });
4070 }
4071
4072 /// Save the walk of the graph that was in hand when a deadline dropped
4073 /// `sync` (bsv-low #555): the per-peer deadline (D16) defers it like the
4074 /// per-graph one, so the next pass resumes it instead of walking it
4075 /// again. No-op without a per-graph budget.
4076 ///
4077 /// The graph in flight is HELD at once, whatever it fetched: its root
4078 /// outpoint and partial walk become a durable record (reason
4079 /// `peer_deadline`) the next sync resumes ahead of its pages, so the
4080 /// cursor passes it to the page's last finished score. Before 0.1.8 a
4081 /// graph the deadline met before its root came back was only a retry,
4082 /// and the next run re-read the same page behind it (bsv-low #552:
4083 /// "the graph in flight is lost whole"). A graph the deadline met after
4084 /// its refusal is finished, not held. A graph that cannot be held (no
4085 /// room) stays the cursor's gap and is named in `cursor_held_by`.
4086 pub async fn defer_in_flight(&mut self) {
4087 let in_flight = self.in_flight.take();
4088 if self.graph_budget.is_none() {
4089 return;
4090 }
4091 if let Some((_, outpoint)) = &in_flight {
4092 if self.expired.borrow().contains(outpoint) {
4093 let outpoint = outpoint.clone();
4094 self.walk.borrow_mut().take();
4095 self.completed_cursor = self.cursor_after_in_flight;
4096 self.release_hold(&outpoint);
4097 return;
4098 }
4099 }
4100 // An unanchorable root cut mid-walk stays unanchorable (engine
4101 // 0.1.9): its record is kept, its backoff starts again, the cursor
4102 // passes it as before. A root whose fault streak is still under the
4103 // threshold is held at the deadline as any graph in flight.
4104 let unanchorable = self
4105 .walk
4106 .borrow()
4107 .as_ref()
4108 .and_then(|w| w.struck.as_deref().filter(|r| r.is_unanchorable()).cloned());
4109 if let Some(record) = unanchorable {
4110 self.walk.borrow_mut().take();
4111 let outpoint = record.outpoint.clone();
4112 if let Err(e) = self.keep_unanchorable(&record).await {
4113 warn!(
4114 "{} Could not keep unanchorable root {} at the deadline: {}",
4115 self.log_prefix, outpoint, e
4116 );
4117 }
4118 self.completed_cursor = self.cursor_after_in_flight;
4119 self.release_hold(&outpoint);
4120 return;
4121 }
4122 // A deadline during pagination has no graph to save. Preserve the
4123 // completed cursor, including any earlier unsaved gap.
4124 if self.walk.borrow().is_none() {
4125 let Some((score, outpoint)) = in_flight else {
4126 return;
4127 };
4128 *self.walk.borrow_mut() = Some(Self::fresh_walk(score, &outpoint, PassCap::NONE));
4129 }
4130 let outpoint = self
4131 .walk
4132 .borrow()
4133 .as_ref()
4134 .map(|w| w.record.outpoint.clone())
4135 .unwrap_or_default();
4136 match self.save_walk("peer_deadline", false).await {
4137 Saved::Yes => {
4138 self.completed_cursor = self.cursor_after_in_flight;
4139 self.release_hold(&outpoint);
4140 }
4141 Saved::Unkept(why, mut walk) => self.unkept(why, &mut walk).await,
4142 _ => {}
4143 }
4144 }
4145
4146 /// The graph id of the walk in hand: its root's, else the outpoint.
4147 fn graph_id_of(walk: Option<&Walk>, outpoint: &str) -> String {
4148 walk.and_then(|w| w.record.nodes.first())
4149 .map_or_else(|| outpoint.to_string(), |w| w.node.graph_id.clone())
4150 }
4151
4152 /// `txid.outputIndex` of a node, its txid computed from its raw bytes.
4153 fn node_id(node: &GASPNode) -> Option<String> {
4154 bsv_rs::transaction::Transaction::from_hex(&node.raw_tx)
4155 .ok()
4156 .map(|tx| format!("{}.{}", tx.id(), node.output_index))
4157 }
4158
4159 /// Count a node the graph in hand was SERVED (bsv-low #586): the bytes
4160 /// the peer sent for it, which are the bytes a record keeps of it.
4161 fn count_fetched(&self, node: &GASPNode) {
4162 let bytes = (node.raw_tx.len() + node.proof.as_ref().map_or(0, String::len)) as u64;
4163 self.bytes_fetched.set(self.bytes_fetched.get() + bytes);
4164 if let Some(w) = self.walk.borrow_mut().as_mut() {
4165 w.bytes_this_pass += bytes;
4166 }
4167 }
4168
4169 /// Count one call of the graph in hand.
4170 fn count_call(&self) {
4171 self.calls_made.set(self.calls_made.get() + 1);
4172 if let Some(w) = self.walk.borrow_mut().as_mut() {
4173 w.calls_this_pass += 1;
4174 w.record.calls += 1;
4175 }
4176 }
4177
4178 /// Push a local UTXO's graph to the remote.
4179 async fn push_utxo(&self, utxo: &GASPOutput) -> Result<(), GASPError> {
4180 let outpoint = format!("{}.{}", utxo.txid, utxo.output_index);
4181 debug!("{} Hydrating node for {}", self.log_prefix, outpoint);
4182
4183 let node = self
4184 .storage
4185 .hydrate_gasp_node(&outpoint, &utxo.txid, utxo.output_index, true)
4186 .await?;
4187
4188 self.process_outgoing_node(&node, &mut std::collections::HashSet::new())
4189 .await?;
4190
4191 Ok(())
4192 }
4193
4194 fn root_bound(&self, reason: &'static str) -> WalkEnd {
4195 self.last_fault.set(None);
4196 if let Some(walk) = self.walk.borrow().as_ref() {
4197 self.stats
4198 .borrow_mut()
4199 .root_bounds
4200 .push(crate::sync_load::RootBound {
4201 peer: String::new(),
4202 outpoint: walk.record.outpoint.clone(),
4203 reason: reason.into(),
4204 });
4205 }
4206 WalkEnd::Deferred(reason)
4207 }
4208
4209 /// Walk the graph in hand until nothing is pending (an explicit stack,
4210 /// bsv-low #555; before it a recursion, in the same order: an input's
4211 /// whole branch before its next sibling). Each step's result is
4212 /// committed to the walk only once the step has finished, so a deadline
4213 /// that drops this future leaves the walk as it was before that step.
4214 /// With a per-graph budget the walk stops at a counted limb of it (its
4215 /// calls; the bytes it was served or the nodes it appended, bsv-low #586)
4216 /// or at its `deadline`, and answers `Deferred` with the limb's name;
4217 /// without one it runs to its end. A limb is read BEFORE a step, so a
4218 /// pass always makes one: no node is too big to walk.
4219 async fn walk_graph(
4220 &self,
4221 mut deadline: Option<&mut crate::engine::SleepFuture>,
4222 ) -> Result<WalkEnd, GASPError> {
4223 loop {
4224 let (item, over) = {
4225 let walk = self.walk.borrow();
4226 let Some(walk) = walk.as_ref() else {
4227 return Ok(WalkEnd::Done);
4228 };
4229 (
4230 walk.record.pending.last().cloned(),
4231 self.graph_budget.as_ref().and_then(|_| walk.over()),
4232 )
4233 };
4234 let Some(item) = item else {
4235 return Ok(WalkEnd::Done);
4236 };
4237 // One failed round trip per decoy: an outpoint pruned earlier in
4238 // this sync is not asked for again, even when a later graph of
4239 // the sync names it (two UTXOs of one decoy-bearing transaction).
4240 if item.parent_proven && self.pruned.borrow().contains(&item.outpoint) {
4241 debug!(
4242 "{} Input {} of {:?} was already pruned in this sync; not re-requested",
4243 self.log_prefix, item.outpoint, item.spent_by
4244 );
4245 if let Some(w) = self.walk.borrow_mut().as_mut() {
4246 w.record.pending.pop();
4247 }
4248 continue;
4249 }
4250 if let Some(limb) = over {
4251 return Ok(WalkEnd::Deferred(limb));
4252 }
4253 let step = self.step(&item);
4254 let out = match deadline.as_mut() {
4255 Some(deadline) => match race_or_deadline(step, deadline.as_mut()).await {
4256 Some(out) => out,
4257 None => return Ok(self.root_bound("time")),
4258 },
4259 None => step.await,
4260 };
4261 if crate::meter::root_exhausted() || matches!(out, Err(GASPError::CourierBudget)) {
4262 return Ok(self.root_bound("courier_rungs"));
4263 }
4264 let out = out?;
4265 if matches!(out, StepOut::Expired) {
4266 return Ok(WalkEnd::Expired);
4267 }
4268 self.commit(out);
4269 }
4270 }
4271
4272 /// Commit one finished step to the walk in hand: its item is no longer
4273 /// pending, and an appended node's needed inputs are, the first of them
4274 /// on top.
4275 fn commit(&self, out: StepOut) {
4276 let mut walk = self.walk.borrow_mut();
4277 let Some(walk) = walk.as_mut() else {
4278 return;
4279 };
4280 walk.record.pending.pop();
4281 if let StepOut::Appended {
4282 node_id,
4283 walked,
4284 children,
4285 } = out
4286 {
4287 walk.seen.insert(node_id);
4288 walk.nodes_this_pass += 1;
4289 self.nodes_appended.set(self.nodes_appended.get() + 1);
4290 walk.record.nodes.push(*walked);
4291 walk.record.pending.extend(children.into_iter().rev());
4292 }
4293 }
4294
4295 /// The walk from a root already in hand (the lib tests' entry, from
4296 /// before the walk was a stack): its proof hydrated, then every needed
4297 /// input to the end.
4298 #[cfg(test)]
4299 async fn process_incoming_node(
4300 &self,
4301 node: &GASPNode,
4302 _spent_by: Option<&str>,
4303 _seen: &mut std::collections::HashSet<String>,
4304 ) -> Result<(), GASPError> {
4305 let root = PendingInput {
4306 outpoint: node.graph_id.clone(),
4307 graph_id: node.graph_id.clone(),
4308 metadata: true,
4309 spent_by: None,
4310 parent_proven: false,
4311 };
4312 *self.walk.borrow_mut() = Some(Walk {
4313 record: DeferredGraph {
4314 peer: String::new(),
4315 topic: String::new(),
4316 outpoint: node.graph_id.clone(),
4317 score: 0,
4318 nodes: Vec::new(),
4319 pending: vec![root.clone()],
4320 calls: 0,
4321 passes: 0,
4322 reason: String::new(),
4323 configured: false,
4324 idle_faults: 0,
4325 cursor_passable: false,
4326 faults: 0,
4327 retry_at: 0,
4328 idle_resumes: 0,
4329 unanchorable_retries: 0,
4330 expiry: None,
4331 },
4332 seen: std::collections::HashSet::new(),
4333 calls_this_pass: 0,
4334 bytes_this_pass: 0,
4335 nodes_this_pass: 0,
4336 resumed: false,
4337 cap: PassCap::NONE,
4338 nodes_at_start: 0,
4339 counted: false,
4340 gone_on: false,
4341 half: None,
4342 struck: None,
4343 });
4344 let node = self.hydrate_root(node.clone()).await;
4345 let out = self.absorb(&root, node).await?;
4346 self.commit(out);
4347 self.walk_graph(None).await.map(|_| ())
4348 }
4349
4350 /// The ROOT of a graph: the peer's node for the UTXO, its own proof
4351 /// hydrated by the ancestor fetcher when it has none.
4352 async fn fetch_root(&self, item: &PendingInput) -> Result<Option<GASPNode>, GASPError> {
4353 let (txid, oi) = parse_outpoint(&item.outpoint)
4354 .ok_or_else(|| GASPError::Other(format!("bad outpoint {}", item.outpoint)))?;
4355 self.count_call();
4356 // Boxed: the batch's answer stays off the walk's future (clippy's
4357 // `large_futures` on the sync that holds it).
4358 let node = Box::pin(self.root_node(item, &txid, oi)).await?;
4359 self.count_fetched(&node);
4360 if self.root_expired(&node, &item.outpoint)? {
4361 return Ok(None);
4362 }
4363 Ok(Some(self.hydrate_root(node).await))
4364 }
4365
4366 /// The peer's node for a root: its batch answer, or the single route's.
4367 async fn root_node(
4368 &self,
4369 item: &PendingInput,
4370 txid: &str,
4371 output_index: u32,
4372 ) -> Result<GASPNode, GASPError> {
4373 if let Some(answer) = self.prefetched_root(item).await {
4374 return answer;
4375 }
4376 self.remote
4377 .request_node(&item.graph_id, txid, output_index, item.metadata)
4378 .await
4379 }
4380
4381 /// Queue the page's fresh roots for the batch request (engine 0.2.1):
4382 /// the outputs `fresh` keeps, less those remembered expired and those
4383 /// held in a record (resumed by their own path). A peer asked one node at
4384 /// a time queues nothing.
4385 fn queue_batch(&self, page: impl Iterator<Item = String>, fresh: impl Fn(&str) -> bool) {
4386 self.batch.prefetched.borrow_mut().clear();
4387 let mut queue = self.batch.queue.borrow_mut();
4388 queue.clear();
4389 if self.batch.single_only.get() || !self.remote.batches() {
4390 return;
4391 }
4392 let expired = self.expired.borrow();
4393 let deferred = self.deferred.borrow();
4394 queue.extend(page.filter(|o| fresh(o) && !expired.contains(o) && !deferred.contains(o)));
4395 }
4396
4397 /// Ask the peer once, for the rest of the run, one node at a time.
4398 fn batch_refused(&self, why: &str) {
4399 warn!(
4400 "{} The node batch is refused ({}); the peer is asked one node at a time for the rest of the run (engine 0.2.1)",
4401 self.log_prefix, why
4402 );
4403 self.batch.single_only.set(true);
4404 self.batch.queue.borrow_mut().clear();
4405 }
4406
4407 /// The root's answer from a batch body (engine 0.2.1, #58), or `None`
4408 /// when it is asked on the single route: the peer does not batch, the
4409 /// root was not queued, or the batch did not answer it.
4410 ///
4411 /// A missing answer asks the batch for the queued roots from this one
4412 /// on, up to [`crate::node_batch::NODE_BATCH_MAX_OUTPOINTS`]. Each entry
4413 /// becomes what its single request would have answered: a node is the
4414 /// node, `not_found` is `GASPError::NodeNotFound` (the peer's word, as a
4415 /// single HTTP 400), so the walk, the absence rule, the fault memo and
4416 /// the proof window read it unchanged. Any other entry, and a root the
4417 /// budget left out, is asked again: the left out in the next body, the
4418 /// refused on the single route. A failed batch, or an answer over
4419 /// [`crate::node_batch::NODE_BATCH_MAX_BYTES`], counts nothing against
4420 /// the peer: the run asks it one node at a time from here on.
4421 async fn prefetched_root(&self, item: &PendingInput) -> Option<Result<GASPNode, GASPError>> {
4422 use crate::node_batch::{
4423 refusal, BatchOutpoint, NODE_BATCH_MAX_BYTES, NODE_BATCH_MAX_OUTPOINTS,
4424 };
4425 if item.graph_id != item.outpoint {
4426 return None;
4427 }
4428 if let Some(answer) = self.batch.prefetched.borrow_mut().remove(&item.outpoint) {
4429 return Some(answer);
4430 }
4431 if self.batch.single_only.get() {
4432 return None;
4433 }
4434 let asked: Vec<String> = {
4435 let mut queue = self.batch.queue.borrow_mut();
4436 let at = queue.iter().position(|o| *o == item.outpoint)?;
4437 queue.drain(..at);
4438 let n = queue.len().min(NODE_BATCH_MAX_OUTPOINTS);
4439 queue.drain(..n).collect()
4440 };
4441 let request: Vec<BatchOutpoint> = asked
4442 .iter()
4443 .filter_map(|o| parse_outpoint(o))
4444 .map(|(txid, vout)| BatchOutpoint { txid, vout })
4445 .collect();
4446 let batch = match self.remote.request_nodes(&request).await {
4447 Ok(batch) => batch,
4448 Err(e) => {
4449 self.batch_refused(&e.to_string());
4450 return None;
4451 }
4452 };
4453 if batch.bytes > NODE_BATCH_MAX_BYTES {
4454 self.batch_refused(&format!(
4455 "an answer of {} bytes is over its budget of {NODE_BATCH_MAX_BYTES}",
4456 batch.bytes
4457 ));
4458 return None;
4459 }
4460 let mut answered = std::collections::HashSet::new();
4461 let mut served = 0u64;
4462 {
4463 let mut prefetched = self.batch.prefetched.borrow_mut();
4464 for entry in batch.answer.nodes {
4465 let key = entry.key();
4466 if !asked.contains(&key) || !answered.insert(key.clone()) {
4467 continue;
4468 }
4469 match (entry.node, entry.refused.as_deref()) {
4470 (Some(node), None) => {
4471 served += 1;
4472 prefetched.insert(key, Ok(node));
4473 }
4474 (None, Some(refusal::NOT_FOUND)) => {
4475 prefetched.insert(
4476 key.clone(),
4477 Err(GASPError::NodeNotFound(format!(
4478 "{key}: the peer answered not_found in a node batch"
4479 ))),
4480 );
4481 }
4482 // Asked on the single route, whose answer stands.
4483 _ => {}
4484 }
4485 }
4486 }
4487 crate::meter::batch_nodes(served);
4488 let mut queue = self.batch.queue.borrow_mut();
4489 for left_out in asked
4490 .iter()
4491 .rev()
4492 .filter(|o| !answered.contains(*o) && **o != item.outpoint)
4493 {
4494 queue.push_front(left_out.clone());
4495 }
4496 drop(queue);
4497 self.batch.prefetched.borrow_mut().remove(&item.outpoint)
4498 }
4499
4500 /// The root's own proof, hydrated by the ancestor fetcher when the peer
4501 /// served it with none.
4502 async fn hydrate_root(&self, mut node: GASPNode) -> GASPNode {
4503 // The peer already supplied the raw. Ask our proof source for only
4504 // its verified BUMP, inside the graph and peer deadlines. Production
4505 // re-verifies courier proofs against CHAINTRACKS; the graph's anchor
4506 // check still runs before any admission. A resumed proofless root
4507 // takes this path too, so a kept graph can land once its proof exists.
4508 if node.proof.is_none() {
4509 if let Some(fetcher) = &self.ancestor_fetcher {
4510 if let Some(node_txid) =
4511 Self::node_id(&node).and_then(|id| parse_outpoint(&id).map(|(txid, _)| txid))
4512 {
4513 self.count_call();
4514 node.proof = fetcher.verified_proof_for(&node_txid).await;
4515 }
4516 }
4517 }
4518 self.root_proofs
4519 .borrow_mut()
4520 .insert(node.graph_id.clone(), node.proof.is_some());
4521 node
4522 }
4523
4524 /// One step of the walk: fetch `item`, append it to the graph, name its
4525 /// needed inputs. Nothing of the walk is changed here (see
4526 /// [`Self::walk_graph`]).
4527 async fn step(&self, item: &PendingInput) -> Result<StepOut, GASPError> {
4528 // THE D8 DECOY RULE (zanaadu-v2 #314, the owner's ruling of
4529 // 2026-10-06). A DELIBERATE DIVERGENCE from the reference.
4530 //
4531 // The reference requests every needed input with no catch
4532 // (`GASP.ts:602`, `await this.remote.requestNode(...)` inside
4533 // `processIncomingNode`), so one input the peer cannot serve
4534 // throws out of the walk and the per-UTXO catch in `sync`
4535 // drops that whole UTXO: the graph is never completed.
4536 //
4537 // We keep that for an UNPROVEN parent and diverge for a PROVEN
4538 // one. A proven parent needs no input for SPV; the only
4539 // inputs `find_needed_inputs` returns for it are the ones its
4540 // topic manager NAMED as history. A manager cannot always
4541 // tell which input is the real one: a head covenant that
4542 // signs under ANYONECANPAY lets a spender place a decoy
4543 // witness-shaped input ahead of the real head input, and once
4544 // mined it is permanent. The pairing is that such a manager
4545 // names EVERY witness-shaped input, and the engine PRUNES the
4546 // branch of a named input the PEER DEFINITELY DOES NOT HOLD
4547 // instead of discarding the graph: warn, count, carry on with
4548 // the parent's other named inputs, complete the graph with
4549 // what it has. Under the reference's rule one decoy would
4550 // strand the chain behind it on every sync, forever.
4551 //
4552 // An unproven parent's inputs are SPV necessities, not named
4553 // history: a missing one still fails the UTXO (the `?` below),
4554 // exactly as the reference does.
4555 //
4556 // THE CLASSIFICATION RULE. A named input is pruned only on a
4557 // DEFINITE answer: the peer said it does not hold the
4558 // outpoint, the typed class `GASPError::NodeNotFound`. Every
4559 // other error (the request could not be sent, a timeout, a
4560 // 5xx, a 429, a body that does not parse) is a fault of the
4561 // moment and says nothing about what the peer holds: it
4562 // fails the UTXO as the reference does, the per-UTXO arm of
4563 // `sync` records it in the cursor gap guard, and the next
4564 // sync asks again. Pruning on such a fault would cut the
4565 // REAL head input, finalize a truncated graph, advance the
4566 // cursor and never ask for that history again. The remote
4567 // decides the class: the worker's maps HTTP 400 from
4568 // `/requestForeignGASPNode` to `NodeNotFound` and nothing
4569 // else (`gasp_remote.rs`), because 400 is what the reference
4570 // peer answers for an outpoint it does not hold. Accepted
4571 // residual: the reference answers the same masked 400 when
4572 // its own storage faults inside `provideForeignGASPNode`, so
4573 // that fault at a reference peer reads as "not held".
4574 //
4575 // THE FETCHER ARM NEVER PRUNES. A chain fetcher has no
4576 // definite "cannot serve": every input of a mined transaction
4577 // exists on chain, so each of its errors is a fault of the
4578 // moment (its per-tick budget, a provider outage, a rate
4579 // limit), whatever class it carries. With a real chain
4580 // fetcher a decoy is SERVED from chain (one fetch, no failed
4581 // round trip, no prune), so the prune lives on the peer arm,
4582 // which is what runs when no fetcher is installed.
4583 //
4584 // NOTHING HERE BOUNDS THE DECOYS OF ONE PARENT. The manager's
4585 // list has no cap, and each definite decoy is one sequential
4586 // failed round trip inside the per-peer sync budget. A parent
4587 // with enough of them exceeds that budget and the sync is
4588 // dropped whole; what a dropped sync keeps is bsv-low #552's
4589 // ground, and a cap on the list is the manager's choice.
4590 // Each failure below is marked with its fault class (engine 0.1.9),
4591 // read where the root is dropped.
4592 let node = if item.spent_by.is_none() {
4593 let Some(node) = self
4594 .fetch_root(item)
4595 .await
4596 .map_err(|e| self.faulted(Self::peer_fault_class(&e), e))?
4597 else {
4598 return Ok(StepOut::Expired);
4599 };
4600 node
4601 } else {
4602 let Some((txid, oi)) = parse_outpoint(&item.outpoint) else {
4603 return Ok(StepOut::Pruned);
4604 };
4605 match &self.ancestor_fetcher {
4606 // OPT-IN ancestry hydration (off by default). When a
4607 // fetcher is configured we KNOW the peer cannot serve
4608 // ancestry — e.g. legacy beta stores minimal BEEFs and
4609 // returns HTTP 400 "Incomplete SPV data!" for every
4610 // ancestor — so we SKIP the doomed peer round-trip
4611 // entirely and fetch the ancestor's raw tx from chain
4612 // directly. Asking the peer first would cost one
4613 // sequential request per ancestor (the ~90-deep
4614 // user_registry chain → ~90 round-trips), enough to
4615 // exhaust a worker invocation before the graph finalizes.
4616 //
4617 // The fetcher returns the ancestor's rawtx and, when it
4618 // is mined, its BUMP proof. A proven node ends the walk
4619 // UNLESS its topic manager names inputs. Empty managers
4620 // still stop at the first proven layer; managers needing
4621 // history select their ancestors through this same
4622 // fetcher. Without a proof, every input is requested
4623 // as before.
4624 // The transaction DAG (no cycles), `seen` keyed by
4625 // `txid.outputIndex`, the already-known strip, and the
4626 // manager naming nothing at genesis bound the walk.
4627 // There is no node cap, matching the reference's
4628 // undefined `maxNodesInGraph` in `Engine.startGASPSync`.
4629 // A per-peer budget drops the sync future at its
4630 // deadline: graphs finalized before it stay admitted
4631 // (bsv-low #552), the graph in flight is lost whole,
4632 // and ONE graph whose own walk outlasts the budget
4633 // never completes unless a per-graph budget defers and
4634 // resumes it (bsv-low #555).
4635 //
4636 // Every fetcher error fails the UTXO: the fetcher arm
4637 // never prunes, see the rule above.
4638 //
4639 // THE PEER DECIDES ABSENCE (engine 0.1.15, #56). An input
4640 // of an UNPROVEN node the fetcher cannot serve is asked of
4641 // the peer, one request: a rung fetches, it never decides.
4642 // On a deployment with no rung configured (the stack's
4643 // production since 2026-10-14) the fetcher answers every
4644 // such input "exhausted" with no request, so before 0.1.15
4645 // the peer was never asked and a graph the chain never
4646 // held was named `courier`, never the absence it is. The
4647 // peer serving it, the walk goes on; its `NodeNotFound` is
4648 // its word that nothing of this graph can anchor
4649 // ([`fault_class::NOT_FOUND`]); any other answer keeps the
4650 // fetcher's error and class. A proven node's named inputs
4651 // and a spent courier budget are as before.
4652 Some(fetcher) => {
4653 self.count_call();
4654 let ancestor = match fetcher.fetch_ancestor(&txid).await {
4655 Ok(ancestor) => ancestor,
4656 Err(e) => {
4657 let class = if matches!(e, GASPError::DefinitiveNotFound(_)) {
4658 fault_class::NOT_FOUND
4659 } else {
4660 fault_class::COURIER
4661 };
4662 if item.parent_proven
4663 || matches!(e, GASPError::CourierBudget)
4664 || crate::meter::root_exhausted()
4665 {
4666 return Err(self.faulted(class, e));
4667 }
4668 return match self.ask_peer_for_input(item, &txid, oi).await {
4669 Ok(node) => self
4670 .absorb(item, node)
4671 .await
4672 .map_err(|e| self.faulted(fault_class::VERIFICATION, e)),
4673 Err(peer @ GASPError::NodeNotFound(_)) => {
4674 Err(self.faulted(fault_class::NOT_FOUND, peer))
4675 }
4676 Err(_) => Err(self.faulted(class, e)),
4677 };
4678 }
4679 };
4680 let node = GASPNode {
4681 graph_id: item.graph_id.clone(),
4682 raw_tx: ancestor.raw_tx,
4683 output_index: oi,
4684 proof: ancestor.proof,
4685 tx_metadata: None,
4686 output_metadata: None,
4687 inputs: None,
4688 };
4689 self.count_fetched(&node);
4690 node
4691 }
4692 // No fetcher, an unproven parent: ask the peer; its
4693 // `NodeNotFound` is absence (engine 0.1.15), remembered for
4694 // the rest of the sync.
4695 None if !item.parent_proven => self
4696 .ask_peer_for_input(item, &txid, oi)
4697 .await
4698 .map_err(|e| self.faulted(Self::peer_fault_class(&e), e))?,
4699 // No fetcher, a proven parent → ask the peer.
4700 None => {
4701 self.count_call();
4702 match self
4703 .remote
4704 .request_node(&item.graph_id, &txid, oi, item.metadata)
4705 .await
4706 {
4707 Ok(child_node) => {
4708 self.count_fetched(&child_node);
4709 child_node
4710 }
4711 // Proven parent and a DEFINITE "not held": the D8 prune
4712 // (see the rule above).
4713 Err(e @ GASPError::NodeNotFound(_)) if item.parent_proven => {
4714 warn!(
4715 "{} Pruned input {} named by proven node {}: the peer does not hold it: {}",
4716 self.log_prefix,
4717 item.outpoint,
4718 item.spent_by.as_deref().unwrap_or_default(),
4719 e
4720 );
4721 if self.pruned.borrow_mut().insert(item.outpoint.clone()) {
4722 self.pruned_inputs.set(self.pruned_inputs.get() + 1);
4723 }
4724 return Ok(StepOut::Pruned);
4725 }
4726 // Any other error: propagate, as the reference does.
4727 Err(e) => return Err(self.faulted(Self::peer_fault_class(&e), e)),
4728 }
4729 }
4730 }
4731 };
4732 self.absorb(item, node)
4733 .await
4734 .map_err(|e| self.faulted(fault_class::VERIFICATION, e))
4735 }
4736
4737 /// Ask the peer for `item`, an input an UNPROVEN node needs (engine
4738 /// 0.1.15): one request, or none when the peer already answered in this
4739 /// sync that it does not hold it (counted a peer-absence memo hit).
4740 async fn ask_peer_for_input(
4741 &self,
4742 item: &PendingInput,
4743 txid: &str,
4744 output_index: u32,
4745 ) -> Result<GASPNode, GASPError> {
4746 if self.absent.borrow().contains(&item.outpoint) {
4747 crate::meter::peer_absent_memo_hit();
4748 return Err(GASPError::NodeNotFound(format!(
4749 "{}: the peer answered it does not hold it earlier in this sync",
4750 item.outpoint
4751 )));
4752 }
4753 self.count_call();
4754 match self
4755 .remote
4756 .request_node(&item.graph_id, txid, output_index, item.metadata)
4757 .await
4758 {
4759 Ok(node) => {
4760 self.count_fetched(&node);
4761 Ok(node)
4762 }
4763 Err(e) => {
4764 if matches!(e, GASPError::NodeNotFound(_)) {
4765 self.absent.borrow_mut().insert(item.outpoint.clone());
4766 }
4767 Err(e)
4768 }
4769 }
4770 }
4771
4772 /// The node of `item`, fetched: append it to the graph and name its
4773 /// needed inputs.
4774 async fn absorb(&self, item: &PendingInput, node: GASPNode) -> Result<StepOut, GASPError> {
4775 // Key by the node's own TXID (computed from raw_tx), NOT graph_id (which
4776 // is constant across a whole graph) and NOT the raw_tx hex. Mirrors TS
4777 // @bsv/gasp processIncomingNode: nodeId = `${computeTXID(rawTx)}.${oi}`
4778 // (GASP.js:319) and spentBy = compute36ByteStructure(computeTXID(rawTx), oi)
4779 // (GASP.js:335). Matches the append-side key in gasp_overlay.rs
4780 // (Transaction::from_hex(raw_tx).id()). Using graph_id/raw_tx here orphaned
4781 // every child node → multi-node graphs never assembled → stranded sync.
4782 let node_txid = match bsv_rs::transaction::Transaction::from_hex(&node.raw_tx) {
4783 Ok(tx) => tx.id(),
4784 Err(_) => node.raw_tx[..node.raw_tx.len().min(64)].to_string(),
4785 };
4786 let node_id = format!("{}.{}", node_txid, node.output_index);
4787 if self
4788 .walk
4789 .borrow()
4790 .as_ref()
4791 .is_some_and(|w| w.seen.contains(&node_id))
4792 {
4793 return Ok(StepOut::Seen);
4794 }
4795
4796 self.storage
4797 .append_to_graph(&node, item.spent_by.as_deref())
4798 .await?;
4799
4800 let mut children = Vec::new();
4801 if let Some(needed) = self.storage.find_needed_inputs(&node).await? {
4802 let parent_proven = node.proof.is_some();
4803 for (outpoint, input_req) in &needed.requested_inputs {
4804 if parse_outpoint(outpoint).is_some() {
4805 children.push(PendingInput {
4806 outpoint: outpoint.clone(),
4807 graph_id: node.graph_id.clone(),
4808 metadata: input_req.metadata,
4809 spent_by: Some(node_id.clone()),
4810 parent_proven,
4811 });
4812 }
4813 }
4814 }
4815 Ok(StepOut::Appended {
4816 node_id,
4817 walked: Box::new(WalkedNode {
4818 node,
4819 spent_by: item.spent_by.clone(),
4820 }),
4821 children,
4822 })
4823 }
4824
4825 /// Process an outgoing node: submit to remote, then recursively send requested inputs.
4826 fn process_outgoing_node<'b>(
4827 &'b self,
4828 node: &'b GASPNode,
4829 seen: &'b mut std::collections::HashSet<String>,
4830 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), GASPError>> + 'b>> {
4831 Box::pin(async move {
4832 if self.unidirectional {
4833 return Ok(());
4834 }
4835
4836 let node_id = format!("{}.{}", node.graph_id, node.output_index);
4837 if seen.contains(&node_id) {
4838 return Ok(());
4839 }
4840 seen.insert(node_id);
4841
4842 if let Some(response) = self.remote.submit_node(node).await? {
4843 for (outpoint, input_req) in &response.requested_inputs {
4844 if let Some((txid, oi)) = parse_outpoint(outpoint) {
4845 match self
4846 .storage
4847 .hydrate_gasp_node(&node.graph_id, &txid, oi, input_req.metadata)
4848 .await
4849 {
4850 Ok(hydrated) => {
4851 self.process_outgoing_node(&hydrated, seen).await?;
4852 }
4853 Err(e) => {
4854 error!("{} Error hydrating outgoing node: {}", self.log_prefix, e);
4855 return Ok(()); // Stop this branch, remote will discard
4856 }
4857 }
4858 }
4859 }
4860 }
4861
4862 Ok(())
4863 })
4864 }
4865
4866 /// Validate and finalize a completed graph, or discard on failure.
4867 /// `Ok(true)`: finalized; `Ok(false)`: refused by the anchor check and
4868 /// discarded (a verdict: the cursor moves past it).
4869 async fn complete_graph(&self, graph_id: &str) -> Result<bool, GASPError> {
4870 info!("{} Completing graph: {}", self.log_prefix, graph_id);
4871 match self.storage.validate_graph_anchor(graph_id).await {
4872 Ok(()) => {
4873 self.storage.finalize_graph(graph_id).await?;
4874 let proven =
4875 self.root_proofs
4876 .borrow_mut()
4877 .remove(graph_id)
4878 .or_else(|| {
4879 self.walk.borrow().as_ref().and_then(|w| {
4880 match w.record.nodes.as_slice() {
4881 [root, ..] => Some(root.node.proof.is_some()),
4882 [] => None,
4883 }
4884 })
4885 })
4886 .unwrap_or(false);
4887 let count = if proven {
4888 &self.roots_proven
4889 } else {
4890 &self.roots_unproven
4891 };
4892 count.set(count.get() + 1);
4893 info!("{} Graph finalized: {}", self.log_prefix, graph_id);
4894 Ok(true)
4895 }
4896 Err(e) => {
4897 warn!(
4898 "{} Graph validation failed for {}: {}. Discarding.",
4899 self.log_prefix, graph_id, e
4900 );
4901 self.storage.discard_graph(graph_id).await?;
4902 // The anchor could not be checked (a tracker outage, a
4903 // storage fault, a topic manager that answered the replay
4904 // with an error): no verdict. Fail the UTXO so the gap guard
4905 // re-requests it, instead of advancing the cursor past a
4906 // graph nobody judged (a divergence by addition, bsv-low
4907 // #551: the reference discards and moves on either way).
4908 if matches!(e, GASPError::AnchorUnavailable(_)) {
4909 return Err(e);
4910 }
4911 // A walk past the budget: held the same way, counted apart
4912 // (the E592 lens fold, H1 and L3).
4913 if matches!(e, GASPError::AnchorWalkHeld(_)) {
4914 self.anchor_walk_held.set(self.anchor_walk_held.get() + 1);
4915 return Err(e);
4916 }
4917 self.discarded_graphs.set(self.discarded_graphs.get() + 1);
4918 Ok(false)
4919 }
4920 }
4921 }
4922}
4923
4924/// Parse "txid.outputIndex" into components.
4925pub fn parse_outpoint(s: &str) -> Option<(String, u32)> {
4926 let parts: Vec<&str> = s.splitn(2, '.').collect();
4927 if parts.len() != 2 {
4928 return None;
4929 }
4930 let oi = parts[1].parse::<u32>().ok()?;
4931 Some((parts[0].to_string(), oi))
4932}
4933
4934// ============================================================================
4935// Error type
4936// ============================================================================
4937
4938/// GASP protocol errors.
4939#[derive(Debug, Clone, thiserror::Error)]
4940pub enum GASPError {
4941 /// Every enabled raw rung answered absence, with no skipped or failed rung.
4942 /// Since engine 0.1.15 it counts one run of absence beside the peer's
4943 /// word ([`fault_class::NOT_FOUND`]), never a strike by itself.
4944 #[error("all raw couriers report not found: {0}")]
4945 DefinitiveNotFound(String),
4946 /// The pass spent its courier allowance; retain its pending walk.
4947 #[error("root courier rung budget exhausted")]
4948 CourierBudget,
4949 /// Version mismatch between peers.
4950 #[error("version mismatch: local={local}, remote={remote}")]
4951 VersionMismatch { local: u32, remote: u32 },
4952
4953 /// Invalid timestamp format.
4954 #[error("invalid timestamp: {0}")]
4955 InvalidTimestamp(String),
4956
4957 /// Node not found.
4958 #[error("node not found: {0}")]
4959 NodeNotFound(String),
4960
4961 /// Graph validation failed.
4962 #[error("graph validation failed: {0}")]
4963 ValidationFailed(String),
4964
4965 /// Network/communication error.
4966 #[error("remote error: {0}")]
4967 RemoteError(String),
4968
4969 /// The platform answered before the peer was reached (engine 0.2.0,
4970 /// bsv-overlay-cloudflare #57; [`crate::route_fault`]): a fault of the
4971 /// route, never of the peer. It counts nothing against the peer's
4972 /// health rows and is named `route_fault:<class>`.
4973 #[error("route_fault:{class}: {detail}")]
4974 RouteFault { class: String, detail: String },
4975
4976 /// Storage error.
4977 #[error("storage error: {0}")]
4978 StorageError(String),
4979
4980 /// A graph's anchor could not be CHECKED right now (the chain tracker or
4981 /// a storage read faulted, or the topic manager answered the replay with
4982 /// an error): a fault of the moment, not a verdict on the graph. `GASPSync::complete_graph` discards the graph and fails the
4983 /// UTXO, so the cursor gap guard asks for it again (bsv-low #551).
4984 #[error("anchor unavailable: {0}")]
4985 AnchorUnavailable(String),
4986
4987 /// The anchor's script walk could not run within the engine's walk
4988 /// budget (bsv-low #592; the E592 lens fold, H1): no verdict, and
4989 /// nothing stands in for one (no network accepted these bytes). Held
4990 /// exactly as [`GASPError::AnchorUnavailable`] holds: the graph is not
4991 /// finalized, the UTXO fails and the cursor stays below it, until the
4992 /// peer serves its root PROVEN (#555's `root_proven` restart, whose walk
4993 /// trusts the proof and runs no script). Counted apart
4994 /// (`GASPSync::anchor_walk_held`).
4995 #[error("anchor walk held: {0}")]
4996 AnchorWalkHeld(String),
4997
4998 /// Generic error.
4999 #[error("{0}")]
5000 Other(String),
5001}
5002
5003// ============================================================================
5004// Tests
5005// ============================================================================
5006
5007#[cfg(test)]
5008mod tests {
5009 use super::*;
5010 use std::collections::HashMap;
5011 use std::sync::Mutex;
5012
5013 // ── race_or_deadline (bsv-low#257/#302, generalized here) ──────────
5014
5015 #[tokio::test]
5016 async fn race_or_deadline_future_wins() {
5017 let out = race_or_deadline(async { 42u32 }, std::future::pending::<()>()).await;
5018 assert_eq!(out, Some(42));
5019 }
5020
5021 #[tokio::test]
5022 async fn race_or_deadline_deadline_wins() {
5023 let out = race_or_deadline(std::future::pending::<u32>(), async {}).await;
5024 assert_eq!(out, None);
5025 }
5026
5027 // The lens fold's HIGH-1: a deadline that falls due inside a write
5028 // section waits for the section to close, then drops the future at its
5029 // next pending point; the writer that asks at the boundary stops there.
5030 #[tokio::test]
5031 async fn guarded_race_never_drops_inside_a_write_section() {
5032 let gate = SubmitGate::default();
5033 let written = std::cell::Cell::new(0u32);
5034 let out = race_or_deadline_guarded(
5035 async {
5036 for _ in 0..3 {
5037 gate.stop_if_due().await;
5038 let _section = gate.write_section();
5039 // Two writes of one transaction, each a round trip.
5040 tokio::task::yield_now().await;
5041 written.set(written.get() + 1);
5042 tokio::task::yield_now().await;
5043 written.set(written.get() + 1);
5044 }
5045 },
5046 async {},
5047 &gate,
5048 )
5049 .await;
5050 assert_eq!(out, None, "the deadline still wins");
5051 assert_eq!(written.get(), 2, "one transaction, whole, and no second");
5052 assert!(
5053 !gate.deadline_is_due(),
5054 "the flag does not outlive the race"
5055 );
5056
5057 // With no section open it is `race_or_deadline`.
5058 let out = race_or_deadline_guarded(std::future::pending::<u8>(), async {}, &gate).await;
5059 assert_eq!(out, None);
5060 let out = race_or_deadline_guarded(async { 7u8 }, std::future::pending(), &gate).await;
5061 assert_eq!(out, Some(7));
5062 // A future that finishes inside its section after the deadline wins.
5063 let out = race_or_deadline_guarded(
5064 async {
5065 let _section = gate.write_section();
5066 tokio::task::yield_now().await;
5067 7u8
5068 },
5069 async {},
5070 &gate,
5071 )
5072 .await;
5073 assert_eq!(out, Some(7));
5074 assert!(!gate.deadline_is_due());
5075 }
5076
5077 #[tokio::test]
5078 async fn race_or_deadline_ready_future_beats_ready_deadline() {
5079 // The sync future is polled FIRST — an instantly-ready result wins
5080 // even against an already-expired deadline (a completed sync is
5081 // never discarded).
5082 let out = race_or_deadline(async { 7u32 }, async {}).await;
5083 assert_eq!(out, Some(7));
5084 }
5085
5086 // ── peer_sync_quarantined (bsv-low#302 pure rule) ──────────────────
5087
5088 fn health(fails: u64, age: Option<u64>) -> crate::storage::PeerSyncHealth {
5089 crate::storage::PeerSyncHealth {
5090 consecutive_failures: fails,
5091 secs_since_last_attempt: age,
5092 }
5093 }
5094
5095 #[test]
5096 fn quarantine_needs_the_full_threshold() {
5097 assert!(!peer_sync_quarantined(&health(0, Some(0))));
5098 assert!(!peer_sync_quarantined(&health(
5099 PEER_QUARANTINE_THRESHOLD - 1,
5100 Some(0)
5101 )));
5102 assert!(peer_sync_quarantined(&health(
5103 PEER_QUARANTINE_THRESHOLD,
5104 Some(0)
5105 )));
5106 assert!(peer_sync_quarantined(&health(
5107 PEER_QUARANTINE_THRESHOLD + 100,
5108 Some(0)
5109 )));
5110 }
5111
5112 #[test]
5113 fn quarantine_reopens_one_probe_after_the_reprobe_window() {
5114 let fails = PEER_QUARANTINE_THRESHOLD;
5115 assert!(peer_sync_quarantined(&health(
5116 fails,
5117 Some(PEER_QUARANTINE_REPROBE_SECS - 1)
5118 )));
5119 // AT the window boundary the probe re-opens — a quarantined peer is
5120 // never stranded forever.
5121 assert!(!peer_sync_quarantined(&health(
5122 fails,
5123 Some(PEER_QUARANTINE_REPROBE_SECS)
5124 )));
5125 }
5126
5127 #[test]
5128 fn never_attempted_peer_is_never_quarantined() {
5129 // `None` age = pristine peer OR a backend without health tracking —
5130 // fail-safe: always attempted.
5131 assert!(!peer_sync_quarantined(&health(u64::MAX, None)));
5132 }
5133
5134 // ── Mock GASPStorage ───────────────────────────────────────────────
5135
5136 struct MockGASPStorage {
5137 utxos: Vec<GASPOutput>,
5138 graphs: Mutex<HashMap<String, Vec<GASPNode>>>,
5139 finalized: Mutex<Vec<String>>,
5140 discarded: Mutex<Vec<String>>,
5141 }
5142
5143 impl MockGASPStorage {
5144 fn new(utxos: Vec<GASPOutput>) -> Self {
5145 Self {
5146 utxos,
5147 graphs: Mutex::new(HashMap::new()),
5148 finalized: Mutex::new(Vec::new()),
5149 discarded: Mutex::new(Vec::new()),
5150 }
5151 }
5152 }
5153
5154 #[async_trait(?Send)]
5155 impl GASPStorage for MockGASPStorage {
5156 // Keeps no deferred graphs (bsv-low #555): never given a per-graph budget.
5157 async fn load_deferred_graphs(
5158 &self,
5159 ) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
5160 Ok(Vec::new())
5161 }
5162 async fn get_deferred_graph(
5163 &self,
5164 _: &str,
5165 ) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
5166 Ok(None)
5167 }
5168 async fn save_deferred_graph(
5169 &self,
5170 _: &crate::gasp::DeferredGraph,
5171 ) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
5172 Err(GASPError::StorageError("keeps no deferred graphs".into()))
5173 }
5174 async fn delete_deferred_graph(&self, _: &str) -> Result<(), GASPError> {
5175 Ok(())
5176 }
5177 async fn find_known_utxos(
5178 &self,
5179 since: u64,
5180 _limit: Option<u64>,
5181 ) -> Result<Vec<GASPOutput>, GASPError> {
5182 Ok(self
5183 .utxos
5184 .iter()
5185 .filter(|u| u.score as u64 >= since)
5186 .cloned()
5187 .collect())
5188 }
5189
5190 async fn hydrate_gasp_node(
5191 &self,
5192 graph_id: &str,
5193 txid: &str,
5194 output_index: u32,
5195 _metadata: bool,
5196 ) -> Result<GASPNode, GASPError> {
5197 Ok(GASPNode {
5198 graph_id: graph_id.to_string(),
5199 raw_tx: format!("rawtx_{txid}"),
5200 output_index,
5201 proof: None,
5202 tx_metadata: None,
5203 output_metadata: None,
5204 inputs: None,
5205 })
5206 }
5207
5208 async fn find_needed_inputs(
5209 &self,
5210 _node: &GASPNode,
5211 ) -> Result<Option<GASPNodeResponse>, GASPError> {
5212 Ok(None) // No inputs needed for simple tests
5213 }
5214
5215 async fn append_to_graph(
5216 &self,
5217 node: &GASPNode,
5218 _spent_by: Option<&str>,
5219 ) -> Result<(), GASPError> {
5220 self.graphs
5221 .lock()
5222 .unwrap()
5223 .entry(node.graph_id.clone())
5224 .or_default()
5225 .push(node.clone());
5226 Ok(())
5227 }
5228
5229 async fn validate_graph_anchor(&self, _graph_id: &str) -> Result<(), GASPError> {
5230 Ok(())
5231 }
5232
5233 async fn finalize_graph(&self, graph_id: &str) -> Result<(), GASPError> {
5234 self.finalized.lock().unwrap().push(graph_id.to_string());
5235 Ok(())
5236 }
5237
5238 async fn discard_graph(&self, graph_id: &str) -> Result<(), GASPError> {
5239 self.discarded.lock().unwrap().push(graph_id.to_string());
5240 Ok(())
5241 }
5242 }
5243
5244 // ── Mock GASPRemote ────────────────────────────────────────────────
5245
5246 struct MockGASPRemote {
5247 remote_utxos: Vec<GASPOutput>,
5248 submitted: Mutex<Vec<GASPNode>>,
5249 }
5250
5251 impl MockGASPRemote {
5252 fn new(utxos: Vec<GASPOutput>) -> Self {
5253 Self {
5254 remote_utxos: utxos,
5255 submitted: Mutex::new(Vec::new()),
5256 }
5257 }
5258 }
5259
5260 #[async_trait(?Send)]
5261 impl GASPRemote for MockGASPRemote {
5262 async fn get_initial_response(
5263 &self,
5264 request: &GASPInitialRequest,
5265 ) -> Result<GASPInitialResponse, GASPError> {
5266 if request.version != GASP_VERSION {
5267 return Err(GASPError::VersionMismatch {
5268 local: GASP_VERSION,
5269 remote: request.version,
5270 });
5271 }
5272 let utxos: Vec<GASPOutput> = self
5273 .remote_utxos
5274 .iter()
5275 .filter(|u| u.score as u64 >= request.since)
5276 .cloned()
5277 .collect();
5278 Ok(GASPInitialResponse {
5279 utxo_list: utxos,
5280 since: request.since,
5281 })
5282 }
5283
5284 async fn get_initial_reply(
5285 &self,
5286 _response: &GASPInitialResponse,
5287 ) -> Result<GASPInitialReply, GASPError> {
5288 Ok(GASPInitialReply {
5289 utxo_list: Vec::new(),
5290 })
5291 }
5292
5293 async fn request_node(
5294 &self,
5295 graph_id: &str,
5296 txid: &str,
5297 output_index: u32,
5298 _metadata: bool,
5299 ) -> Result<GASPNode, GASPError> {
5300 Ok(GASPNode {
5301 graph_id: graph_id.to_string(),
5302 raw_tx: format!("remote_rawtx_{txid}"),
5303 output_index,
5304 proof: Some("proof_hex".to_string()),
5305 tx_metadata: None,
5306 output_metadata: None,
5307 inputs: None,
5308 })
5309 }
5310
5311 async fn submit_node(
5312 &self,
5313 node: &GASPNode,
5314 ) -> Result<Option<GASPNodeResponse>, GASPError> {
5315 self.submitted.lock().unwrap().push(node.clone());
5316 Ok(None) // No further inputs needed
5317 }
5318 }
5319
5320 // ── Tests ──────────────────────────────────────────────────────────
5321
5322 #[tokio::test]
5323 async fn proofs_page_deadline_never_passes_a_failed_unsaved_graph() {
5324 struct PageDeadline(std::rc::Rc<std::cell::Cell<bool>>);
5325 #[async_trait(?Send)]
5326 impl GASPRemote for PageDeadline {
5327 async fn get_initial_response(
5328 &self,
5329 request: &GASPInitialRequest,
5330 ) -> Result<GASPInitialResponse, GASPError> {
5331 if request.since > 0 {
5332 self.0.set(true);
5333 std::future::pending::<()>().await;
5334 }
5335 Ok(GASPInitialResponse {
5336 since: request.since,
5337 utxo_list: vec![GASPOutput {
5338 txid: "11".repeat(32),
5339 output_index: 0,
5340 score: 10.0,
5341 }],
5342 })
5343 }
5344 async fn get_initial_reply(
5345 &self,
5346 _: &GASPInitialResponse,
5347 ) -> Result<GASPInitialReply, GASPError> {
5348 unreachable!()
5349 }
5350 async fn request_node(
5351 &self,
5352 _: &str,
5353 _: &str,
5354 _: u32,
5355 _: bool,
5356 ) -> Result<GASPNode, GASPError> {
5357 Err(GASPError::RemoteError("offline".into()))
5358 }
5359 async fn submit_node(
5360 &self,
5361 _: &GASPNode,
5362 ) -> Result<Option<GASPNodeResponse>, GASPError> {
5363 unreachable!()
5364 }
5365 }
5366 let due = std::rc::Rc::new(std::cell::Cell::new(false));
5367 let mut sync = GASPSync::new(
5368 Box::new(MockGASPStorage::new(vec![])),
5369 Box::new(PageDeadline(due.clone())),
5370 0,
5371 "test",
5372 true,
5373 )
5374 .with_graph_budget(GraphBudget {
5375 max_calls: 10,
5376 max_bytes_fetched: u64::MAX,
5377 max_nodes: u32::MAX,
5378 deadline: Box::new(|| Box::pin(std::future::pending())),
5379 half_deadline: None,
5380 });
5381 let deadline = std::future::poll_fn(move |_| {
5382 if due.get() {
5383 std::task::Poll::Ready(())
5384 } else {
5385 std::task::Poll::Pending
5386 }
5387 });
5388 assert!(race_or_deadline(sync.sync(None), deadline).await.is_none());
5389 assert!(!sync.graph_in_flight());
5390 sync.defer_in_flight().await;
5391 assert_eq!(
5392 sync.completed_cursor(),
5393 0,
5394 "a failed graph has no durable hold"
5395 );
5396 assert_eq!(
5397 sync.cursor_held_by(),
5398 Some(format!("{}.0", "11".repeat(32)))
5399 );
5400 }
5401
5402 #[tokio::test]
5403 async fn test_sync_pulls_remote_utxos() {
5404 let local_storage = MockGASPStorage::new(vec![]);
5405 let remote = MockGASPRemote::new(vec![
5406 GASPOutput {
5407 txid: "tx1".to_string(),
5408 output_index: 0,
5409 score: 100.0,
5410 },
5411 GASPOutput {
5412 txid: "tx2".to_string(),
5413 output_index: 0,
5414 score: 200.0,
5415 },
5416 ]);
5417
5418 let mut gasp = GASPSync::new(
5419 Box::new(local_storage),
5420 Box::new(remote),
5421 0,
5422 "[TEST]",
5423 false,
5424 );
5425
5426 gasp.sync(None).await.unwrap();
5427
5428 assert_eq!(gasp.last_interaction, 200);
5429 }
5430
5431 #[tokio::test]
5432 async fn test_sync_skips_known_utxos() {
5433 let local_storage = MockGASPStorage::new(vec![GASPOutput {
5434 txid: "tx1".to_string(),
5435 output_index: 0,
5436 score: 100.0,
5437 }]);
5438 let remote = MockGASPRemote::new(vec![
5439 GASPOutput {
5440 txid: "tx1".to_string(),
5441 output_index: 0,
5442 score: 100.0,
5443 },
5444 GASPOutput {
5445 txid: "tx2".to_string(),
5446 output_index: 0,
5447 score: 200.0,
5448 },
5449 ]);
5450
5451 let mut gasp = GASPSync::new(
5452 Box::new(local_storage),
5453 Box::new(remote),
5454 0,
5455 "[TEST]",
5456 true, // unidirectional — skip push
5457 );
5458
5459 gasp.sync(None).await.unwrap();
5460
5461 // Only tx2 should have been ingested (tx1 was already known)
5462 // last_interaction should be 200
5463 assert_eq!(gasp.last_interaction, 200);
5464 }
5465
5466 #[tokio::test]
5467 async fn test_sync_unidirectional_skips_push() {
5468 let local_storage = MockGASPStorage::new(vec![GASPOutput {
5469 txid: "local_only".to_string(),
5470 output_index: 0,
5471 score: 50.0,
5472 }]);
5473 let remote = MockGASPRemote::new(vec![]);
5474
5475 let mut gasp = GASPSync::new(Box::new(local_storage), Box::new(remote), 0, "[TEST]", true);
5476
5477 gasp.sync(None).await.unwrap();
5478 // In unidirectional mode, local_only should NOT be pushed to remote
5479 // (no way to check directly with current mock, but no error = success)
5480 }
5481
5482 #[tokio::test]
5483 async fn test_complete_graph_finalizes_on_valid() {
5484 let storage = MockGASPStorage::new(vec![]);
5485 let remote = MockGASPRemote::new(vec![]);
5486
5487 let gasp = GASPSync::new(Box::new(storage), Box::new(remote), 0, "[TEST]", false);
5488
5489 gasp.complete_graph("test_graph.0").await.unwrap();
5490 // Storage mock always validates OK, so graph should be finalized
5491 }
5492
5493 #[tokio::test]
5494 async fn test_complete_graph_discards_on_invalid() {
5495 struct FailValidationStorage;
5496
5497 #[async_trait(?Send)]
5498 impl GASPStorage for FailValidationStorage {
5499 // Keeps no deferred graphs (bsv-low #555): never given a per-graph budget.
5500 async fn load_deferred_graphs(
5501 &self,
5502 ) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
5503 Ok(Vec::new())
5504 }
5505 async fn get_deferred_graph(
5506 &self,
5507 _: &str,
5508 ) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
5509 Ok(None)
5510 }
5511 async fn save_deferred_graph(
5512 &self,
5513 _: &crate::gasp::DeferredGraph,
5514 ) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
5515 Err(GASPError::StorageError("keeps no deferred graphs".into()))
5516 }
5517 async fn delete_deferred_graph(&self, _: &str) -> Result<(), GASPError> {
5518 Ok(())
5519 }
5520 async fn find_known_utxos(
5521 &self,
5522 _: u64,
5523 _: Option<u64>,
5524 ) -> Result<Vec<GASPOutput>, GASPError> {
5525 Ok(vec![])
5526 }
5527 async fn hydrate_gasp_node(
5528 &self,
5529 _: &str,
5530 _: &str,
5531 _: u32,
5532 _: bool,
5533 ) -> Result<GASPNode, GASPError> {
5534 unreachable!()
5535 }
5536 async fn find_needed_inputs(
5537 &self,
5538 _: &GASPNode,
5539 ) -> Result<Option<GASPNodeResponse>, GASPError> {
5540 Ok(None)
5541 }
5542 async fn append_to_graph(
5543 &self,
5544 _: &GASPNode,
5545 _: Option<&str>,
5546 ) -> Result<(), GASPError> {
5547 Ok(())
5548 }
5549 async fn validate_graph_anchor(&self, _: &str) -> Result<(), GASPError> {
5550 Err(GASPError::ValidationFailed("bad anchor".into()))
5551 }
5552 async fn finalize_graph(&self, _: &str) -> Result<(), GASPError> {
5553 panic!("should not finalize")
5554 }
5555 async fn discard_graph(&self, _: &str) -> Result<(), GASPError> {
5556 Ok(())
5557 }
5558 }
5559
5560 let gasp = GASPSync::new(
5561 Box::new(FailValidationStorage),
5562 Box::new(MockGASPRemote::new(vec![])),
5563 0,
5564 "[TEST]",
5565 false,
5566 );
5567
5568 // Should not error — discards instead of finalizing
5569 gasp.complete_graph("bad_graph.0").await.unwrap();
5570 }
5571
5572 // ── AncestorFetcher fallback tests ─────────────────────────────────
5573
5574 /// Build a minimal valid tx hex with one input referencing `source_txid`.
5575 fn make_tx_hex(source_txid: &str, source_oi: u32) -> String {
5576 let mut tx = bsv_rs::transaction::Transaction::new();
5577 tx.inputs.push(bsv_rs::transaction::TransactionInput::new(
5578 source_txid.to_string(),
5579 source_oi,
5580 ));
5581 tx.outputs.push(bsv_rs::transaction::TransactionOutput::new(
5582 100,
5583 bsv_rs::script::LockingScript::from_hex(
5584 "76a914000000000000000000000000000000000000000088ac",
5585 )
5586 .unwrap(),
5587 ));
5588 tx.to_hex()
5589 }
5590
5591 /// Storage that records appended nodes and requests exactly one ancestor
5592 /// (the first input) on the FIRST node it sees, then nothing further.
5593 struct RecordingStorage {
5594 appended: Mutex<Vec<GASPNode>>,
5595 request_once: Mutex<bool>,
5596 ancestor_outpoint: String,
5597 }
5598
5599 #[async_trait(?Send)]
5600 impl GASPStorage for RecordingStorage {
5601 // Keeps no deferred graphs (bsv-low #555): never given a per-graph budget.
5602 async fn load_deferred_graphs(
5603 &self,
5604 ) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
5605 Ok(Vec::new())
5606 }
5607 async fn get_deferred_graph(
5608 &self,
5609 _: &str,
5610 ) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
5611 Ok(None)
5612 }
5613 async fn save_deferred_graph(
5614 &self,
5615 _: &crate::gasp::DeferredGraph,
5616 ) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
5617 Err(GASPError::StorageError("keeps no deferred graphs".into()))
5618 }
5619 async fn delete_deferred_graph(&self, _: &str) -> Result<(), GASPError> {
5620 Ok(())
5621 }
5622 async fn find_known_utxos(
5623 &self,
5624 _: u64,
5625 _: Option<u64>,
5626 ) -> Result<Vec<GASPOutput>, GASPError> {
5627 Ok(vec![])
5628 }
5629 async fn hydrate_gasp_node(
5630 &self,
5631 _: &str,
5632 _: &str,
5633 _: u32,
5634 _: bool,
5635 ) -> Result<GASPNode, GASPError> {
5636 unreachable!()
5637 }
5638 async fn find_needed_inputs(
5639 &self,
5640 _: &GASPNode,
5641 ) -> Result<Option<GASPNodeResponse>, GASPError> {
5642 let mut once = self.request_once.lock().unwrap();
5643 if *once {
5644 *once = false;
5645 let mut requested_inputs = HashMap::new();
5646 requested_inputs.insert(
5647 self.ancestor_outpoint.clone(),
5648 crate::types::GASPInputRequest { metadata: false },
5649 );
5650 Ok(Some(GASPNodeResponse { requested_inputs }))
5651 } else {
5652 Ok(None)
5653 }
5654 }
5655 async fn append_to_graph(&self, node: &GASPNode, _: Option<&str>) -> Result<(), GASPError> {
5656 self.appended.lock().unwrap().push(node.clone());
5657 Ok(())
5658 }
5659 async fn validate_graph_anchor(&self, _: &str) -> Result<(), GASPError> {
5660 Ok(())
5661 }
5662 async fn finalize_graph(&self, _: &str) -> Result<(), GASPError> {
5663 Ok(())
5664 }
5665 async fn discard_graph(&self, _: &str) -> Result<(), GASPError> {
5666 Ok(())
5667 }
5668 }
5669
5670 /// Remote that always errors on `request_node` (mimics beta's HTTP 400
5671 /// "Incomplete SPV data!").
5672 struct FailingNodeRemote;
5673
5674 #[async_trait(?Send)]
5675 impl GASPRemote for FailingNodeRemote {
5676 async fn get_initial_response(
5677 &self,
5678 _: &GASPInitialRequest,
5679 ) -> Result<GASPInitialResponse, GASPError> {
5680 Ok(GASPInitialResponse {
5681 utxo_list: vec![],
5682 since: 0,
5683 })
5684 }
5685 async fn get_initial_reply(
5686 &self,
5687 _: &GASPInitialResponse,
5688 ) -> Result<GASPInitialReply, GASPError> {
5689 Ok(GASPInitialReply { utxo_list: vec![] })
5690 }
5691 async fn request_node(
5692 &self,
5693 _: &str,
5694 _: &str,
5695 _: u32,
5696 _: bool,
5697 ) -> Result<GASPNode, GASPError> {
5698 Err(GASPError::RemoteError(
5699 "Peer returned HTTP 400: Incomplete SPV data!".to_string(),
5700 ))
5701 }
5702 async fn submit_node(&self, _: &GASPNode) -> Result<Option<GASPNodeResponse>, GASPError> {
5703 Ok(None)
5704 }
5705 }
5706
5707 /// Mock fetcher that returns a pre-built ancestor rawtx (+ optional proof).
5708 struct MockFetcher {
5709 ancestor_hex: String,
5710 proof: Option<String>,
5711 called: Mutex<u32>,
5712 }
5713
5714 #[async_trait(?Send)]
5715 impl AncestorFetcher for MockFetcher {
5716 async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
5717 *self.called.lock().unwrap() += 1;
5718 Ok(FetchedAncestor {
5719 raw_tx: self.ancestor_hex.clone(),
5720 proof: self.proof.clone(),
5721 })
5722 }
5723 }
5724
5725 /// Remote that records how many times `request_node` is invoked (it would
5726 /// be a doomed peer call on the migration path) so a test can assert the
5727 /// fetcher path SKIPS the peer entirely.
5728 struct CountingNodeRemote {
5729 request_node_calls: std::rc::Rc<Mutex<u32>>,
5730 }
5731
5732 #[async_trait(?Send)]
5733 impl GASPRemote for CountingNodeRemote {
5734 async fn get_initial_response(
5735 &self,
5736 _: &GASPInitialRequest,
5737 ) -> Result<GASPInitialResponse, GASPError> {
5738 Ok(GASPInitialResponse {
5739 utxo_list: vec![],
5740 since: 0,
5741 })
5742 }
5743 async fn get_initial_reply(
5744 &self,
5745 _: &GASPInitialResponse,
5746 ) -> Result<GASPInitialReply, GASPError> {
5747 Ok(GASPInitialReply { utxo_list: vec![] })
5748 }
5749 async fn request_node(
5750 &self,
5751 _: &str,
5752 _: &str,
5753 _: u32,
5754 _: bool,
5755 ) -> Result<GASPNode, GASPError> {
5756 *self.request_node_calls.lock().unwrap() += 1;
5757 Err(GASPError::RemoteError(
5758 "Peer returned HTTP 400: Incomplete SPV data!".to_string(),
5759 ))
5760 }
5761 async fn submit_node(&self, _: &GASPNode) -> Result<Option<GASPNodeResponse>, GASPError> {
5762 Ok(None)
5763 }
5764 }
5765
5766 #[tokio::test]
5767 async fn ancestor_fetcher_none_preserves_peer_error() {
5768 // Build a tip tx whose input references some ancestor.
5769 let ancestor_hex = make_tx_hex(
5770 "1111111111111111111111111111111111111111111111111111111111111111",
5771 0,
5772 );
5773 let ancestor_txid = bsv_rs::transaction::Transaction::from_hex(&ancestor_hex)
5774 .unwrap()
5775 .id();
5776 let tip_hex = make_tx_hex(&ancestor_txid, 0);
5777
5778 let storage = RecordingStorage {
5779 appended: Mutex::new(vec![]),
5780 request_once: Mutex::new(true),
5781 ancestor_outpoint: format!("{ancestor_txid}.0"),
5782 };
5783
5784 // No fetcher configured → default path → peer error must propagate.
5785 let gasp = GASPSync::new(
5786 Box::new(storage),
5787 Box::new(FailingNodeRemote),
5788 0,
5789 "[TEST]",
5790 true,
5791 );
5792
5793 let tip_node = GASPNode {
5794 graph_id: format!("{}.0", "deadbeef"),
5795 raw_tx: tip_hex,
5796 output_index: 0,
5797 proof: None,
5798 tx_metadata: None,
5799 output_metadata: None,
5800 inputs: None,
5801 };
5802
5803 let mut seen = std::collections::HashSet::new();
5804 let result = gasp.process_incoming_node(&tip_node, None, &mut seen).await;
5805 assert!(
5806 result.is_err(),
5807 "with no fetcher, the peer error must propagate (default path unchanged)"
5808 );
5809 }
5810
5811 #[tokio::test]
5812 async fn ancestor_fetcher_some_self_heals_missing_ancestor() {
5813 let ancestor_hex = make_tx_hex(
5814 "2222222222222222222222222222222222222222222222222222222222222222",
5815 0,
5816 );
5817 let ancestor_txid = bsv_rs::transaction::Transaction::from_hex(&ancestor_hex)
5818 .unwrap()
5819 .id();
5820 let tip_hex = make_tx_hex(&ancestor_txid, 0);
5821
5822 let storage = RecordingStorage {
5823 appended: Mutex::new(vec![]),
5824 request_once: Mutex::new(true),
5825 ancestor_outpoint: format!("{ancestor_txid}.0"),
5826 };
5827
5828 let fetcher = std::rc::Rc::new(MockFetcher {
5829 ancestor_hex: ancestor_hex.clone(),
5830 proof: None,
5831 called: Mutex::new(0),
5832 });
5833
5834 let gasp = GASPSync::new(
5835 Box::new(storage),
5836 Box::new(FailingNodeRemote),
5837 0,
5838 "[TEST]",
5839 true,
5840 )
5841 .with_ancestor_fetcher(Some(fetcher.clone()));
5842
5843 let tip_node = GASPNode {
5844 graph_id: "deadbeef.0".to_string(),
5845 raw_tx: tip_hex.clone(),
5846 output_index: 0,
5847 proof: None,
5848 tx_metadata: None,
5849 output_metadata: None,
5850 inputs: None,
5851 };
5852
5853 let mut seen = std::collections::HashSet::new();
5854 gasp.process_incoming_node(&tip_node, None, &mut seen)
5855 .await
5856 .expect("with fetcher, missing ancestor self-heals from chain");
5857
5858 assert_eq!(
5859 *fetcher.called.lock().unwrap(),
5860 2,
5861 "fetcher fires twice: once to hydrate the root's OWN proof (mock returns \
5862 proof:None here, so the root stays proofless + the walk continues), then \
5863 once to self-heal the missing ancestor (#126 root proof-anchoring)"
5864 );
5865
5866 // The synthesized ancestor node must have been appended to the graph,
5867 // with proof: None so the existing recursion would continue upward.
5868 // (We can't reach into the boxed storage here, but the Ok(()) above
5869 // proves the synthesized node flowed through process_incoming_node and
5870 // append_to_graph without error.)
5871 }
5872
5873 #[tokio::test]
5874 async fn ancestor_fetcher_some_skips_peer_request_node() {
5875 // Proof of the peer-skip optimization: with a fetcher present, the
5876 // doomed peer `request_node` call must NOT fire for ancestors — the
5877 // fetcher is consulted directly. (Otherwise a deep chain pays one
5878 // sequential peer round-trip per ancestor before falling back.)
5879 let ancestor_hex = make_tx_hex(
5880 "3333333333333333333333333333333333333333333333333333333333333333",
5881 0,
5882 );
5883 let ancestor_txid = bsv_rs::transaction::Transaction::from_hex(&ancestor_hex)
5884 .unwrap()
5885 .id();
5886 let tip_hex = make_tx_hex(&ancestor_txid, 0);
5887
5888 let storage = RecordingStorage {
5889 appended: Mutex::new(vec![]),
5890 request_once: Mutex::new(true),
5891 ancestor_outpoint: format!("{ancestor_txid}.0"),
5892 };
5893
5894 let counter = std::rc::Rc::new(Mutex::new(0u32));
5895 let remote = CountingNodeRemote {
5896 request_node_calls: counter.clone(),
5897 };
5898 let fetcher = std::rc::Rc::new(MockFetcher {
5899 ancestor_hex: ancestor_hex.clone(),
5900 proof: None,
5901 called: Mutex::new(0),
5902 });
5903
5904 let gasp = GASPSync::new(Box::new(storage), Box::new(remote), 0, "[TEST]", true)
5905 .with_ancestor_fetcher(Some(fetcher.clone()));
5906
5907 let tip_node = GASPNode {
5908 graph_id: "deadbeef.0".to_string(),
5909 raw_tx: tip_hex,
5910 output_index: 0,
5911 proof: None,
5912 tx_metadata: None,
5913 output_metadata: None,
5914 inputs: None,
5915 };
5916
5917 let mut seen = std::collections::HashSet::new();
5918 gasp.process_incoming_node(&tip_node, None, &mut seen)
5919 .await
5920 .expect("self-heal via fetcher should succeed");
5921
5922 assert_eq!(
5923 *counter.lock().unwrap(),
5924 0,
5925 "peer request_node must be SKIPPED for ancestors when a fetcher is present"
5926 );
5927 assert_eq!(
5928 *fetcher.called.lock().unwrap(),
5929 2,
5930 "fetcher fires twice: root's OWN proof hydration (#126) + serving the \
5931 ancestor directly (peer request_node still skipped)"
5932 );
5933 }
5934
5935 #[test]
5936 fn test_parse_outpoint() {
5937 let (txid, oi) = parse_outpoint("abc123.5").unwrap();
5938 assert_eq!(txid, "abc123");
5939 assert_eq!(oi, 5);
5940
5941 assert!(parse_outpoint("no_dot").is_none());
5942 assert!(parse_outpoint("abc.notanum").is_none());
5943 }
5944
5945 #[tokio::test]
5946 async fn test_gasp_storage_is_object_safe() {
5947 let storage: Box<dyn GASPStorage> = Box::new(MockGASPStorage::new(vec![]));
5948 let utxos = storage.find_known_utxos(0, None).await.unwrap();
5949 assert!(utxos.is_empty());
5950 }
5951
5952 #[tokio::test]
5953 async fn test_gasp_remote_is_object_safe() {
5954 let remote: Box<dyn GASPRemote> = Box::new(MockGASPRemote::new(vec![]));
5955 let response = remote
5956 .get_initial_response(&GASPInitialRequest {
5957 version: 1,
5958 since: 0,
5959 limit: None,
5960 })
5961 .await
5962 .unwrap();
5963 assert!(response.utxo_list.is_empty());
5964 }
5965
5966 #[tokio::test]
5967 async fn test_version_mismatch_error() {
5968 let remote = MockGASPRemote::new(vec![]);
5969 let result = remote
5970 .get_initial_response(&GASPInitialRequest {
5971 version: 99,
5972 since: 0,
5973 limit: None,
5974 })
5975 .await;
5976 assert!(result.is_err());
5977 assert!(matches!(
5978 result.unwrap_err(),
5979 GASPError::VersionMismatch { .. }
5980 ));
5981 }
5982}