1use crate::beef_limits;
10
11use bsv_rs::transaction::{Beef, Transaction};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use tracing::{error, info, warn};
15
16use crate::advertiser::{Advertiser, AdvertiserError};
17use crate::broadcaster::{ArcBroadcaster, Broadcaster};
18use crate::lookup_service::{LookupService, LookupServiceError};
19use crate::storage::{Storage, StorageError};
20use crate::topic_manager::{TopicManager, TopicManagerError};
21use crate::types::*;
22
23#[derive(Debug, Clone)]
28pub enum HistorySelector {
29 Depth(u32),
31}
32
33pub struct EngineConfig {
35 pub hosting_url: Option<String>,
37 pub ship_trackers: Vec<String>,
39 pub slap_trackers: Vec<String>,
41 pub sync_configuration: SyncConfiguration,
43 pub suppress_default_sync_advertisements: bool,
45}
46
47impl Default for EngineConfig {
48 fn default() -> Self {
49 Self {
50 hosting_url: None,
51 ship_trackers: Vec::new(),
52 slap_trackers: Vec::new(),
53 sync_configuration: HashMap::new(),
54 suppress_default_sync_advertisements: true,
55 }
56 }
57}
58
59pub struct Engine {
64 managers: HashMap<String, Box<dyn TopicManager>>,
65 lookup_services: HashMap<String, Box<dyn LookupService>>,
66 storage: Box<dyn Storage>,
67 advertiser: Option<Box<dyn Advertiser>>,
68 broadcaster: Option<Box<dyn Broadcaster>>,
69 arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
70 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
71 gasp_remote_factory: Option<Box<dyn crate::gasp::GASPRemoteFactory>>,
72 ship_lookup_client: Option<Box<dyn crate::ship_lookup::ShipLookupClient>>,
73 ship_bootstrap_peers: HashMap<String, Vec<String>>,
74 ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
79 gasp_ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
80 peer_sync_budget: Option<(SleepFactory, u64)>,
87 graph_budget: Option<(SleepFactory, u32, u64)>,
90 graph_budget_limbs: (u64, u32),
94 finalize_submit_budget: Option<(SleepFactory, u64)>,
97 finalize_gate: crate::gasp::SubmitGate,
101 not_landed: std::cell::RefCell<HashSet<(String, String)>>,
107 landing_guard: Option<LandingGuard>,
110 verify_scripts: bool,
122 walk_budget: DoorBudget,
126 config: EngineConfig,
127}
128
129pub type SleepFuture = std::pin::Pin<Box<dyn std::future::Future<Output = ()>>>;
132
133pub type SleepFactory = std::rc::Rc<dyn Fn(u64) -> SleepFuture>;
138
139pub type LandingGuardFuture =
142 std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
143
144pub type LandingGuard = std::rc::Rc<dyn Fn(&str, &str) -> LandingGuardFuture>;
148
149struct CallBound {
162 sleep: SleepFactory,
163 budget_ms: u64,
164 deadline: std::cell::RefCell<SleepFuture>,
165 due: std::cell::Cell<bool>,
166}
167
168impl CallBound {
169 fn new(sleep: &SleepFactory, budget_ms: u64) -> Self {
170 Self {
171 deadline: std::cell::RefCell::new(sleep(budget_ms)),
172 sleep: sleep.clone(),
173 budget_ms,
174 due: std::cell::Cell::new(false),
175 }
176 }
177
178 fn renew(&self) {
182 *self.deadline.borrow_mut() = (self.sleep)(self.budget_ms);
183 self.due.set(false);
184 }
185
186 fn is_due(&self) -> bool {
189 self.due.get()
190 }
191
192 async fn call<T>(&self, call: impl std::future::Future<Output = T>) -> Option<T> {
194 if self.due.get() {
195 return None;
196 }
197 let mut call = std::pin::pin!(call);
198 std::future::poll_fn(|cx| {
199 if let std::task::Poll::Ready(v) = call.as_mut().poll(cx) {
200 return std::task::Poll::Ready(Some(v));
201 }
202 if self.deadline.borrow_mut().as_mut().poll(cx).is_ready() {
203 self.due.set(true);
204 return std::task::Poll::Ready(None);
205 }
206 std::task::Poll::Pending
207 })
208 .await
209 }
210}
211
212async fn bounded<T>(
214 bound: Option<&CallBound>,
215 call: impl std::future::Future<Output = T>,
216) -> Result<T, String> {
217 match bound {
218 Some(bound) => bound
219 .call(call)
220 .await
221 .ok_or_else(|| format!("no answer within {} ms", bound.budget_ms)),
222 None => Ok(call.await),
223 }
224}
225
226async fn stored<T>(
228 bound: Option<&CallBound>,
229 call: impl std::future::Future<Output = Result<T, StorageError>>,
230) -> Result<T, StorageError> {
231 match bounded(bound, call).await {
232 Ok(answer) => answer,
233 Err(no_answer) => Err(StorageError::Database(no_answer)),
234 }
235}
236
237async fn hooked(
239 bound: Option<&CallBound>,
240 call: impl std::future::Future<Output = Result<(), LookupServiceError>>,
241) -> Result<(), String> {
242 bounded(bound, call).await?.map_err(|e| e.to_string())
243}
244
245const PREDECESSOR_READS: usize = 16;
255
256const PREDECESSOR_READS_PER_SUBMIT: usize = 256;
267
268type Counted = (Steak, MutationReport, HashMap<String, String>);
274
275#[derive(Debug, Clone, Copy)]
279struct Landing {
280 policy: WalkBreachPolicy,
281 subject_walk_ran: bool,
282}
283
284struct NotNow {
285 why: String,
286 carried: Option<String>,
287}
288
289#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
294pub struct ProofCompletionSummary {
295 pub early_gasp: usize,
297 pub scanned: usize,
299 pub proofless: usize,
301 pub completed: usize,
303 pub still_unconfirmed: usize,
306 pub fetch_failed: usize,
309 pub stitch_failed: usize,
311 pub already_proven: usize,
315}
316
317#[derive(Debug, Default, Clone, Serialize)]
327#[serde(rename_all = "camelCase")]
328pub struct SyncAdvertisementsReport {
329 pub to_create: usize,
332 pub to_revoke: usize,
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub create_error: Option<String>,
337 #[serde(skip_serializing_if = "Option::is_none")]
342 pub submit_error: Option<String>,
343 pub admitted: std::collections::BTreeMap<String, usize>,
347 #[serde(skip_serializing_if = "Option::is_none")]
351 pub lookup_error: Option<String>,
352 pub revoke_skipped: usize,
356 #[serde(skip_serializing_if = "Option::is_none")]
358 pub revoke_error: Option<String>,
359 #[serde(skip_serializing_if = "Option::is_none")]
361 pub revoke_submit_error: Option<String>,
362}
363
364impl SyncAdvertisementsReport {
365 #[must_use]
369 pub fn ok(&self) -> bool {
370 self.create_error.is_none()
371 && self.submit_error.is_none()
372 && self.lookup_error.is_none()
373 && self.revoke_error.is_none()
374 && self.revoke_submit_error.is_none()
375 }
376
377 #[must_use]
379 pub fn admitted_total(&self) -> usize {
380 self.admitted.values().sum()
381 }
382
383 #[must_use]
393 pub fn effective(&self) -> bool {
394 self.ok() && self.admitted_total() >= self.to_create
395 }
396}
397
398struct TopicValidation {
403 topic: String,
404 is_dupe: bool,
405 previous_coins: Vec<u32>,
407 previous_outputs: Vec<Output>,
409 admittance: AdmittanceInstructions,
410 failed: bool,
411 read_fault: Option<(&'static str, String)>,
421}
422
423#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct MutationFault {
434 pub topic: String,
436 pub site: &'static str,
445 pub error: String,
447}
448
449#[derive(Debug, Default, Clone, PartialEq, Eq)]
458pub struct MutationReport {
459 pub faults: Vec<MutationFault>,
460 pub applied_topics: Vec<String>,
461 pub deduped_topics: Vec<String>,
466 pub landed_predecessors: Vec<(String, String)>,
472 pub walk_could_not_run: Option<WalkCouldNotRun>,
479 pub landed_walks_could_not_run: Vec<WalkCouldNotRun>,
485}
486
487#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
489pub struct RenotifyReport {
490 pub outputs: u32,
492 pub notified: u32,
494 pub faults: Vec<String>,
496 pub vouts: Vec<u32>,
498}
499
500impl MutationReport {
501 fn fault(&mut self, topic: &str, site: &'static str, error: String) {
502 self.faults.push(MutationFault {
503 topic: topic.to_string(),
504 site,
505 error,
506 });
507 }
508
509 #[must_use]
512 pub fn is_durable(&self) -> bool {
513 self.faults.is_empty()
514 }
515
516 #[must_use]
519 pub fn summary(&self) -> String {
520 self.faults
521 .iter()
522 .map(|f| {
523 let err: String = f.error.chars().take(120).collect();
524 format!("{}/{}: {}", f.topic, f.site, err)
525 })
526 .collect::<Vec<_>>()
527 .join("; ")
528 }
529}
530
531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553pub struct DoorBudget {
554 pub memory_limit: usize,
559 pub max_work_bytes: u64,
561 pub sig_check_floor: u64,
571 pub max_memory_bytes: u64,
590}
591
592impl DoorBudget {
593 pub const DEFAULT: DoorBudget = DoorBudget {
670 memory_limit: 128 * 1024,
671 max_work_bytes: 64 * 1024 * 1024,
672 sig_check_floor: 64 * 1024,
673 max_memory_bytes: 48 * 1024 * 1024,
674 };
675}
676
677#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679pub enum DoorLimb {
680 Work,
683 Memory,
685}
686
687type Validated = (
691 Vec<TopicValidation>,
692 Steak,
693 Transaction,
694 String,
695 Option<WalkCouldNotRun>,
696);
697
698#[derive(Debug, Clone, Copy, PartialEq, Eq)]
702pub enum WalkLimb {
703 OverWork,
705 OverMemory,
707 InterpreterMemory,
710}
711
712impl WalkLimb {
713 #[must_use]
716 pub fn door_limb(self) -> DoorLimb {
717 match self {
718 WalkLimb::OverWork | WalkLimb::InterpreterMemory => DoorLimb::Work,
719 WalkLimb::OverMemory => DoorLimb::Memory,
720 }
721 }
722
723 #[must_use]
725 pub fn as_str(self) -> &'static str {
726 match self {
727 WalkLimb::OverWork => "over_work",
728 WalkLimb::OverMemory => "over_memory",
729 WalkLimb::InterpreterMemory => "interpreter_memory",
730 }
731 }
732}
733
734#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
745pub enum WalkBreachPolicy {
746 #[default]
751 NotNow,
752 NetworkAccepted,
758}
759
760#[derive(Debug, Clone, PartialEq, Eq)]
770pub struct WalkCouldNotRun {
771 pub subject_txid: String,
773 pub at_txid: String,
775 pub subject_judged: bool,
777 pub limb: WalkLimb,
779 pub what: String,
781}
782
783#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
786pub struct WalkStats {
787 pub unproven_txs: usize,
789 pub inputs_executed: usize,
791 pub script_bytes: usize,
793 pub hash_ops: usize,
795 pub sig_ops: usize,
798 pub work_bytes: u64,
800 pub memory_bytes: u64,
804 pub subject_judged: bool,
807}
808
809pub(crate) fn script_census(
816 unlocking: &[u8],
817 locking: &[u8],
818 memory_limit: usize,
819) -> Result<(usize, usize, usize), String> {
820 use bsv_rs::script::op::*;
821 let most_keys = (memory_limit / 33).max(1);
822 let mut hash_ops = 0usize;
823 let mut sig_ops = 0usize;
824 for (label, bytes) in [("unlocking", unlocking), ("locking", locking)] {
825 let script = bsv_rs::script::Script::from_binary(bytes)
826 .map_err(|e| format!("{label} script does not parse: {e}"))?;
827 let chunks = script.chunks();
828 for (i, chunk) in chunks.iter().enumerate() {
829 match chunk.op {
830 OP_RIPEMD160 | OP_SHA1 | OP_SHA256 | OP_HASH160 | OP_HASH256 => hash_ops += 1,
831 OP_CHECKSIG | OP_CHECKSIGVERIFY => sig_ops += 1,
832 OP_CHECKMULTISIG | OP_CHECKMULTISIGVERIFY => {
833 let stated = i
834 .checked_sub(1)
835 .map(|j| chunks[j].op)
836 .and_then(|op| match op {
837 OP_1..=OP_16 => Some((op - OP_1 + 1) as usize),
838 _ => None,
839 });
840 sig_ops += stated.unwrap_or(most_keys);
841 }
842 _ => {}
843 }
844 }
845 }
846 Ok((unlocking.len() + locking.len(), hash_ops, sig_ops))
847}
848
849impl Engine {
850 pub const SYNC_RESPONSE_DEFAULT_LIMIT: u64 = 500;
857 pub const SYNC_RESPONSE_MAX_LIMIT: u64 = 1000;
861
862 pub fn clamp_sync_limit(requested: Option<u64>) -> u64 {
869 requested
870 .unwrap_or(Self::SYNC_RESPONSE_DEFAULT_LIMIT)
871 .min(Self::SYNC_RESPONSE_MAX_LIMIT)
872 }
873
874 pub fn new(
876 managers: HashMap<String, Box<dyn TopicManager>>,
877 lookup_services: HashMap<String, Box<dyn LookupService>>,
878 storage: Box<dyn Storage>,
879 advertiser: Option<Box<dyn Advertiser>>,
880 config: EngineConfig,
881 ) -> Self {
882 Self::with_chain_tracker(
883 managers,
884 lookup_services,
885 storage,
886 advertiser,
887 None,
888 None,
889 config,
890 )
891 }
892
893 pub fn with_chain_tracker(
895 managers: HashMap<String, Box<dyn TopicManager>>,
896 lookup_services: HashMap<String, Box<dyn LookupService>>,
897 storage: Box<dyn Storage>,
898 advertiser: Option<Box<dyn Advertiser>>,
899 broadcaster: Option<Box<dyn Broadcaster>>,
900 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
901 config: EngineConfig,
902 ) -> Self {
903 Self::with_all(
904 managers,
905 lookup_services,
906 storage,
907 advertiser,
908 broadcaster,
909 None,
910 chain_tracker,
911 config,
912 )
913 }
914
915 #[allow(clippy::too_many_arguments)]
917 pub fn with_all(
918 managers: HashMap<String, Box<dyn TopicManager>>,
919 lookup_services: HashMap<String, Box<dyn LookupService>>,
920 storage: Box<dyn Storage>,
921 advertiser: Option<Box<dyn Advertiser>>,
922 broadcaster: Option<Box<dyn Broadcaster>>,
923 arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
924 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
925 mut config: EngineConfig,
926 ) -> Self {
927 for manager_name in managers.keys() {
930 if manager_name == "tm_ship" {
931 if matches!(
932 config.sync_configuration.get(manager_name),
933 Some(SyncTarget::Disabled)
934 ) {
935 continue;
936 }
937 let mut combined: HashSet<String> = HashSet::new();
938 if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
939 {
940 combined.extend(peers.iter().cloned());
941 }
942 combined.extend(config.ship_trackers.iter().cloned());
943 if !combined.is_empty() {
944 config.sync_configuration.insert(
945 manager_name.clone(),
946 SyncTarget::Peers(combined.into_iter().collect()),
947 );
948 }
949 } else if manager_name == "tm_slap" {
950 if matches!(
951 config.sync_configuration.get(manager_name),
952 Some(SyncTarget::Disabled)
953 ) {
954 continue;
955 }
956 let mut combined: HashSet<String> = HashSet::new();
957 if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
958 {
959 combined.extend(peers.iter().cloned());
960 }
961 combined.extend(config.slap_trackers.iter().cloned());
962 if !combined.is_empty() {
963 config.sync_configuration.insert(
964 manager_name.clone(),
965 SyncTarget::Peers(combined.into_iter().collect()),
966 );
967 }
968 } else if !config.sync_configuration.contains_key(manager_name) {
969 config
970 .sync_configuration
971 .insert(manager_name.clone(), SyncTarget::Ship);
972 }
973 }
974
975 Self {
976 managers,
977 lookup_services,
978 storage: Box::new(crate::load_storage::MeteredStorage(storage)),
979 advertiser,
980 broadcaster,
981 arc_broadcaster,
982 chain_tracker,
983 gasp_remote_factory: None,
984 ship_lookup_client: None,
985 ship_bootstrap_peers: HashMap::new(),
986 ancestor_fetcher: None,
987 gasp_ancestor_fetcher: None,
988 peer_sync_budget: None,
989 graph_budget: None,
990 graph_budget_limbs: (
991 crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES,
992 crate::gasp::DEFAULT_GRAPH_BUDGET_NODES,
993 ),
994 finalize_submit_budget: None,
995 finalize_gate: crate::gasp::SubmitGate::default(),
996 not_landed: std::cell::RefCell::new(HashSet::new()),
997 landing_guard: None,
998 verify_scripts: true,
999 walk_budget: DoorBudget::DEFAULT,
1000 config,
1001 }
1002 }
1003
1004 pub fn set_arc_broadcaster(&mut self, arc: Box<dyn ArcBroadcaster>) {
1009 self.arc_broadcaster = Some(arc);
1010 }
1011
1012 pub fn set_gasp_remote_factory(&mut self, factory: Box<dyn crate::gasp::GASPRemoteFactory>) {
1017 self.gasp_remote_factory = Some(factory);
1018 }
1019
1020 pub fn set_ship_lookup_client(
1023 &mut self,
1024 client: Box<dyn crate::ship_lookup::ShipLookupClient>,
1025 ) {
1026 self.ship_lookup_client = Some(client);
1027 }
1028
1029 pub fn set_ship_bootstrap_peers(&mut self, topic: impl Into<String>, peers: Vec<String>) {
1034 self.ship_bootstrap_peers.insert(topic.into(), peers);
1035 }
1036
1037 pub fn set_ancestor_fetcher(&mut self, fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>) {
1047 self.ancestor_fetcher = Some(fetcher);
1048 }
1049
1050 pub fn set_gasp_ancestor_fetcher(
1052 &mut self,
1053 fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>,
1054 ) {
1055 self.gasp_ancestor_fetcher = Some(fetcher);
1056 }
1057
1058 pub fn set_landing_guard(&mut self, guard: LandingGuard) {
1072 self.landing_guard = Some(guard);
1073 }
1074
1075 pub fn set_peer_sync_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1111 self.peer_sync_budget = Some((sleep, budget_ms));
1112 }
1113
1114 pub fn set_graph_budget(&mut self, sleep: SleepFactory, max_calls: u32, budget_ms: u64) {
1173 self.graph_budget = Some((sleep, max_calls, budget_ms));
1174 }
1175
1176 pub fn set_graph_budget_limbs(&mut self, max_bytes_fetched: u64, max_nodes: u32) {
1197 self.graph_budget_limbs = (max_bytes_fetched, max_nodes);
1198 }
1199
1200 pub fn set_finalize_submit_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1221 self.finalize_submit_budget = Some((sleep, budget_ms));
1222 }
1223
1224 pub fn finalize_submit_gate(&self) -> &crate::gasp::SubmitGate {
1232 &self.finalize_gate
1233 }
1234
1235 pub fn set_script_verification(&mut self, enabled: bool) {
1249 self.verify_scripts = enabled;
1250 }
1251
1252 pub fn script_verification(&self) -> bool {
1255 self.verify_scripts
1256 }
1257
1258 pub fn set_walk_budget(&mut self, budget: DoorBudget) {
1270 self.walk_budget = budget;
1271 }
1272
1273 pub fn walk_budget(&self) -> DoorBudget {
1276 self.walk_budget
1277 }
1278
1279 #[allow(
1314 clippy::unused_async,
1315 reason = "the door's callers await it on an engine; 'scripts only' asks no tracker"
1316 )]
1317 pub async fn verify_scripts_only(
1318 &self,
1319 beef_bytes: &[u8],
1320 subject_txid: &str,
1321 ) -> Result<WalkStats, EngineError> {
1322 crate::script_door::walk(beef_bytes, subject_txid, self.walk_budget)
1323 }
1324
1325 #[allow(
1329 clippy::unused_async,
1330 clippy::unused_self,
1331 reason = "the twin of `verify_scripts_only`"
1332 )]
1333 pub async fn verify_scripts_only_under(
1334 &self,
1335 beef_bytes: &[u8],
1336 subject_txid: &str,
1337 budget: DoorBudget,
1338 ) -> Result<WalkStats, EngineError> {
1339 crate::script_door::walk(beef_bytes, subject_txid, budget)
1340 }
1341
1342 pub async fn submit_validate_only(
1359 &self,
1360 tagged_beef: &TaggedBEEF,
1361 mode: SubmitMode,
1362 ) -> Result<Steak, EngineError> {
1363 let (_validations, steak, _tx, _txid, _could_not_run) = self
1367 .run_validation(
1368 tagged_beef,
1369 mode,
1370 &TopicAdmittanceContext::DRY_RUN,
1371 None,
1372 None,
1373 WalkBreachPolicy::NotNow,
1374 )
1375 .await?;
1376 Ok(steak)
1377 }
1378
1379 pub async fn submit(
1393 &self,
1394 tagged_beef: &TaggedBEEF,
1395 mode: SubmitMode,
1396 ) -> Result<Steak, EngineError> {
1397 let (steak, report) = self.submit_with_report(tagged_beef, mode).await?;
1398 if !report.is_durable() {
1399 warn!(
1400 "submit: {} mutation fault(s) — not durable: {}",
1401 report.faults.len(),
1402 report.summary()
1403 );
1404 }
1405 Ok(steak)
1406 }
1407
1408 pub async fn submit_with_report(
1451 &self,
1452 tagged_beef: &TaggedBEEF,
1453 mode: SubmitMode,
1454 ) -> Result<(Steak, MutationReport), EngineError> {
1455 self.submit_with_report_under(tagged_beef, mode, WalkBreachPolicy::NotNow)
1456 .await
1457 }
1458
1459 pub async fn submit_with_report_under(
1466 &self,
1467 tagged_beef: &TaggedBEEF,
1468 mode: SubmitMode,
1469 policy: WalkBreachPolicy,
1470 ) -> Result<(Steak, MutationReport), EngineError> {
1471 self.submit_bounded(tagged_beef, mode, None, false, policy)
1472 .await
1473 }
1474
1475 async fn submit_bounded(
1481 &self,
1482 tagged_beef: &TaggedBEEF,
1483 mode: SubmitMode,
1484 bound: Option<&CallBound>,
1485 finalize: bool,
1486 policy: WalkBreachPolicy,
1487 ) -> Result<(Steak, MutationReport), EngineError> {
1488 let mut question_reads = 0usize;
1492 self.submit_counted(
1493 tagged_beef,
1494 mode,
1495 bound,
1496 finalize,
1497 None,
1498 &mut question_reads,
1499 policy,
1500 )
1501 .await
1502 .map(|(steak, report, _)| (steak, report))
1503 }
1504
1505 #[allow(clippy::too_many_arguments)]
1519 fn submit_counted<'a>(
1520 &'a self,
1521 tagged_beef: &'a TaggedBEEF,
1522 mode: SubmitMode,
1523 bound: Option<&'a CallBound>,
1524 finalize: bool,
1525 carried: Option<&'a HashSet<String>>,
1526 question_reads: &'a mut usize,
1527 policy: WalkBreachPolicy,
1528 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Counted, EngineError>> + 'a>>
1529 {
1530 Box::pin(async move {
1531 self.submit_counted_inner(
1532 tagged_beef,
1533 mode,
1534 bound,
1535 finalize,
1536 carried,
1537 question_reads,
1538 policy,
1539 )
1540 .await
1541 })
1542 }
1543
1544 #[allow(clippy::too_many_arguments)]
1545 async fn submit_counted_inner(
1546 &self,
1547 tagged_beef: &TaggedBEEF,
1548 mode: SubmitMode,
1549 bound: Option<&CallBound>,
1550 finalize: bool,
1551 walked: Option<&HashSet<String>>,
1552 question_reads: &mut usize,
1553 policy: WalkBreachPolicy,
1554 ) -> Result<Counted, EngineError> {
1555 let carried = walked.is_some();
1558 let context = if carried {
1559 TopicAdmittanceContext::DRY_RUN
1560 } else {
1561 TopicAdmittanceContext::default()
1562 };
1563 let (mut validations, mut steak, tx, txid, could_not_run) = self
1564 .run_validation(tagged_beef, mode, &context, bound, walked, policy)
1565 .await?;
1566 if finalize {
1567 let now = (current_timestamp_ms() / 1000.0) as u64;
1568 for validation in &mut validations {
1569 let manager = &self.managers[&validation.topic];
1570 validation.admittance.outputs_to_admit.retain(|index| {
1571 tx.outputs.get(*index as usize).is_none_or(|output| {
1572 manager
1573 .sync_expiry(&output.locking_script)
1574 .is_none_or(|expiry| expiry > now)
1575 })
1576 });
1577 steak.insert(validation.topic.clone(), validation.admittance.clone());
1578 }
1579 }
1580 let mut report = MutationReport {
1581 walk_could_not_run: could_not_run,
1582 ..MutationReport::default()
1583 };
1584 let subject_named_beef: Vec<u8> =
1594 beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
1595 .ok()
1596 .and_then(|mut b| b.to_binary_atomic(&txid).ok())
1597 .unwrap_or_else(|| tagged_beef.beef.clone());
1598
1599 let mut blocked: HashMap<String, String> = HashMap::new();
1603 let landing = Landing {
1606 policy,
1607 subject_walk_ran: report.walk_could_not_run.is_none(),
1608 };
1609 let waits = self
1610 .successors_waiting(
1611 &tx,
1612 &txid,
1613 &subject_named_beef,
1614 &mut validations,
1615 &mut steak,
1616 tagged_beef.off_chain_values.as_deref(),
1617 mode,
1618 bound,
1619 finalize,
1620 !carried,
1621 &mut blocked,
1622 question_reads,
1623 &mut report.landed_predecessors,
1624 landing,
1625 &mut report.landed_walks_could_not_run,
1626 )
1627 .await;
1628 if carried {
1631 for v in &mut validations {
1632 if v.is_dupe || v.failed || v.read_fault.is_some() || waits.contains_key(&v.topic) {
1633 continue;
1634 }
1635 match self
1636 .judge(
1637 &tx,
1638 &v.topic,
1639 &v.previous_coins,
1640 tagged_beef.off_chain_values.as_deref(),
1641 mode,
1642 &TopicAdmittanceContext::default(),
1643 bound,
1644 )
1645 .await
1646 {
1647 Ok(admittance) => v.admittance = admittance,
1648 Err(e) => {
1649 error!("Error validating topic {} during submit: {e}", v.topic);
1650 v.failed = true;
1651 v.previous_coins.clear();
1652 v.previous_outputs.clear();
1653 v.admittance = AdmittanceInstructions::default();
1654 }
1655 }
1656 steak.insert(v.topic.clone(), v.admittance.clone());
1657 }
1658 }
1659
1660 for v in &validations {
1664 if v.is_dupe {
1665 report.deduped_topics.push(v.topic.clone());
1666 continue;
1667 }
1668 if v.failed {
1674 continue;
1675 }
1676
1677 let topic = &v.topic;
1678 let admittance = &v.admittance;
1679 let faults_before = report.faults.len();
1680
1681 if let Some((read, err)) = &v.read_fault {
1687 report.fault(topic, read, err.clone());
1688 self.hold_unapplied(&txid, topic, "a validation read faulted, nothing written");
1689 continue;
1690 }
1691
1692 if let Some(not_now) = waits.get(topic) {
1696 report.fault(topic, "predecessor_not_landed", not_now.clone());
1697 self.hold_unapplied(
1698 &txid,
1699 topic,
1700 &format!("found no coin and its predecessor {not_now}"),
1701 );
1702 continue;
1703 }
1704
1705 let mut outputs_consumed: Vec<Outpoint> = Vec::new();
1707 let mut stale_coins: Vec<&Output> = Vec::new();
1708
1709 for (coin_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1710 let input_index = v.previous_coins[coin_idx];
1711 if admittance.coins_to_retain.contains(&input_index) {
1712 outputs_consumed
1714 .push(Outpoint::new(&prev_output.txid, prev_output.output_index));
1715 } else {
1716 stale_coins.push(prev_output);
1718 }
1719 }
1720
1721 if let Some(steak_entry) = steak.get_mut(topic) {
1723 steak_entry.coins_removed = Some(
1724 stale_coins
1725 .iter()
1726 .enumerate()
1727 .filter_map(|(i, _)| {
1728 v.previous_coins.get(i).copied()
1730 })
1731 .collect(),
1732 );
1733 }
1734
1735 let mut retained_mark_faulted = false;
1743 for (prev_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1744 if let Err(e) = stored(
1745 bound,
1746 self.storage.mark_utxo_as_spent(
1747 &prev_output.txid,
1748 prev_output.output_index,
1749 topic,
1750 ),
1751 )
1752 .await
1753 {
1754 error!("Error marking UTXO as spent: {e}");
1755 report.fault(topic, "mark_utxo_as_spent", e.to_string());
1756 retained_mark_faulted |= admittance
1757 .coins_to_retain
1758 .contains(&v.previous_coins[prev_idx]);
1759 }
1760
1761 let spending_input_idx = v.previous_coins[prev_idx] as usize;
1763
1764 for ls in self.lookup_services.values() {
1766 let payload = match ls.spend_notification_mode() {
1767 SpendNotificationMode::None => OutputSpent::None {
1768 txid: prev_output.txid.clone(),
1769 output_index: prev_output.output_index,
1770 topic: topic.clone(),
1771 },
1772 SpendNotificationMode::Txid => OutputSpent::Txid {
1773 txid: prev_output.txid.clone(),
1774 output_index: prev_output.output_index,
1775 topic: topic.clone(),
1776 spending_txid: txid.clone(),
1777 },
1778 SpendNotificationMode::Script => {
1779 let input = &tx.inputs[spending_input_idx];
1781 let unlocking_script = input
1782 .unlocking_script
1783 .as_ref()
1784 .map(bsv_rs::UnlockingScript::to_binary)
1785 .unwrap_or_default();
1786 OutputSpent::Script {
1787 txid: prev_output.txid.clone(),
1788 output_index: prev_output.output_index,
1789 topic: topic.clone(),
1790 spending_txid: txid.clone(),
1791 input_index: spending_input_idx as u32,
1792 unlocking_script,
1793 sequence_number: input.sequence,
1794 off_chain_values: tagged_beef.off_chain_values.clone(),
1795 }
1796 }
1797 SpendNotificationMode::WholeTx => OutputSpent::WholeTx {
1798 txid: prev_output.txid.clone(),
1799 output_index: prev_output.output_index,
1800 topic: topic.clone(),
1801 spending_atomic_beef: subject_named_beef.clone(),
1802 off_chain_values: tagged_beef.off_chain_values.clone(),
1803 },
1804 };
1805 if let Err(e) = hooked(bound, ls.output_spent(&payload)).await {
1806 error!("Error notifying lookup service of spent output: {e}");
1807 report.fault(topic, "lookup_service.output_spent", e);
1808 }
1809 }
1810 }
1811 if retained_mark_faulted {
1812 self.hold_unapplied(
1813 &txid,
1814 topic,
1815 "the spent mark of a retained coin faulted, nothing inserted",
1816 );
1817 continue;
1818 }
1819
1820 let mut new_utxos: Vec<Outpoint> = Vec::new();
1824 let mut attempted: Vec<u32> = Vec::new();
1825 let mut insert_faulted = false;
1826
1827 for &output_index in &admittance.outputs_to_admit {
1828 let (script_bytes, sats) =
1829 if let Some(tx_output) = tx.outputs.get(output_index as usize) {
1830 (
1831 tx_output.locking_script.to_binary(),
1832 tx_output.get_satoshis(),
1833 )
1834 } else {
1835 (Vec::new(), 0)
1836 };
1837
1838 let output = Output {
1839 txid: txid.clone(),
1840 output_index,
1841 output_script: script_bytes,
1842 satoshis: sats,
1843 topic: topic.clone(),
1844 spent: false,
1845 outputs_consumed: outputs_consumed.clone(),
1846 consumed_by: Vec::new(),
1847 beef: Some(tagged_beef.beef.clone()),
1848 block_height: None,
1849 score: Some(current_timestamp_ms()),
1850 };
1851
1852 attempted.push(output_index);
1853 if let Err(e) = stored(
1854 bound,
1855 self.storage.insert_output_with_origin(&output, finalize),
1856 )
1857 .await
1858 {
1859 error!("Error inserting output for topic {topic}: {e}");
1860 report.fault(topic, "insert_output", e.to_string());
1861 insert_faulted = true;
1862 break;
1863 }
1864
1865 new_utxos.push(Outpoint::new(&txid, output_index));
1866
1867 for ls in self.lookup_services.values() {
1869 let (ls_script, ls_sats) =
1870 if let Some(tx_out) = tx.outputs.get(output_index as usize) {
1871 (tx_out.locking_script.to_binary(), tx_out.get_satoshis())
1872 } else {
1873 (Vec::new(), 0)
1874 };
1875
1876 let payload = match ls.admission_mode() {
1877 AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
1878 txid: txid.clone(),
1879 output_index,
1880 topic: topic.clone(),
1881 satoshis: ls_sats,
1882 locking_script: ls_script,
1883 off_chain_values: tagged_beef.off_chain_values.clone(),
1884 },
1885 AdmissionMode::WholeTx => OutputAdmittedByTopic::WholeTx {
1886 atomic_beef: subject_named_beef.clone(),
1887 output_index,
1888 topic: topic.clone(),
1889 off_chain_values: tagged_beef.off_chain_values.clone(),
1890 },
1891 };
1892
1893 if let Err(e) = hooked(bound, ls.output_admitted_by_topic(&payload)).await {
1894 error!("Error notifying lookup service: {e}");
1895 report.fault(topic, "lookup_service.output_admitted_by_topic", e);
1896 }
1897 }
1898 }
1899
1900 if insert_faulted {
1911 if let Some(bound) = bound {
1912 bound.renew();
1913 }
1914 self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
1915 .await;
1916 self.hold_unapplied(
1917 &txid,
1918 topic,
1919 &format!(
1920 "an insert faulted: {} previous coin(s) kept, nothing of it held",
1921 stale_coins.len()
1922 ),
1923 );
1924 continue;
1925 }
1926
1927 let due_before_the_delete = bound.is_some_and(CallBound::is_due);
1951 let mut delete_faulted = false;
1952 let mut delete_started = false;
1953 for stale in &stale_coins {
1954 let spent_coin_tx = AppliedTransaction {
1955 txid: stale.txid.clone(),
1956 topic: topic.clone(),
1957 };
1958 if let Err(e) = stored(
1959 bound,
1960 self.storage.insert_applied_transaction(&spent_coin_tx),
1961 )
1962 .await
1963 {
1964 error!(
1965 "Error recording {} as applied before its coin is deleted from topic {topic}: {e}",
1966 stale.txid
1967 );
1968 report.fault(topic, "record_spent_coin_applied", e.to_string());
1969 delete_faulted = true;
1970 continue;
1971 }
1972 self.not_landed
1973 .borrow_mut()
1974 .remove(&(stale.txid.clone(), topic.clone()));
1975 match stored(
1976 bound,
1977 self.storage.find_output(
1978 &stale.txid,
1979 stale.output_index,
1980 Some(topic),
1981 None,
1982 false,
1983 ),
1984 )
1985 .await
1986 {
1987 Ok(Some(stale_output)) => {
1988 delete_started = true;
1989 if let Err(e) = self.delete_utxo_deep(&stale_output, bound).await {
1990 error!("Error deleting stale output for topic {topic}: {e}");
1991 report.fault(topic, "delete_utxo_deep", e.to_string());
1992 delete_faulted = true;
1993 }
1994 }
1995 Ok(None) => {}
1996 Err(e) => {
1997 error!("Error reading stale output for topic {topic}: {e}");
1998 report.fault(topic, "find_output", e.to_string());
1999 delete_faulted = true;
2000 }
2001 }
2002 }
2003
2004 if delete_faulted {
2035 let unanswered = !due_before_the_delete && bound.is_some_and(CallBound::is_due);
2036 if let Some(bound) = bound {
2037 bound.renew();
2038 }
2039 let mut all_held = !unanswered && !delete_started;
2040 for stale in &stale_coins {
2041 if !all_held {
2042 break;
2043 }
2044 let read = stored(
2045 bound,
2046 self.storage.find_output(
2047 &stale.txid,
2048 stale.output_index,
2049 Some(topic),
2050 None,
2051 false,
2052 ),
2053 )
2054 .await;
2055 if !matches!(read, Ok(Some(_))) {
2056 all_held = false;
2057 break;
2058 }
2059 }
2060 if all_held {
2061 self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
2062 .await;
2063 }
2064 self.hold_unapplied(
2065 &txid,
2066 topic,
2067 if all_held {
2068 "no delete of a stale coin was started: the coins are held, its outputs taken out"
2069 } else if unanswered {
2070 "the delete of a stale coin did not answer: nothing undone, its outputs stay"
2071 } else if delete_started {
2072 "the delete of a stale coin faulted: nothing undone, its outputs stay"
2073 } else {
2074 "no delete was started and a stale coin was not read back held: nothing undone, its outputs stay"
2075 },
2076 );
2077 continue;
2078 }
2079
2080 for consumed_outpoint in &outputs_consumed {
2082 let consumed_output = match stored(
2083 bound,
2084 self.storage.find_output(
2085 &consumed_outpoint.txid,
2086 consumed_outpoint.output_index,
2087 Some(topic),
2088 None,
2089 false,
2090 ),
2091 )
2092 .await
2093 {
2094 Ok(Some(o)) => o,
2095 Ok(None) => continue,
2096 Err(e) => {
2097 error!("Error reading consumed output for topic {topic}: {e}");
2098 report.fault(topic, "find_output", e.to_string());
2099 continue;
2100 }
2101 };
2102 {
2103 let mut new_consumed_by = consumed_output.consumed_by.clone();
2104 for new_utxo in &new_utxos {
2105 if !new_consumed_by.iter().any(|c| {
2106 c.txid == new_utxo.txid && c.output_index == new_utxo.output_index
2107 }) {
2108 new_consumed_by.push(new_utxo.clone());
2109 }
2110 }
2111 if let Err(e) = stored(
2112 bound,
2113 self.storage.update_consumed_by(
2114 &consumed_outpoint.txid,
2115 consumed_outpoint.output_index,
2116 topic,
2117 &new_consumed_by,
2118 ),
2119 )
2120 .await
2121 {
2122 error!("Error updating consumedBy: {e}");
2123 report.fault(topic, "update_consumed_by", e.to_string());
2124 }
2125 }
2126 }
2127
2128 if report.faults.len() > faults_before {
2132 self.hold_unapplied(
2133 &txid,
2134 topic,
2135 &format!(
2136 "{} mutation fault(s); a replay of these bytes re-applies",
2137 report.faults.len() - faults_before
2138 ),
2139 );
2140 continue;
2141 }
2142 let tx_record = AppliedTransaction {
2143 txid: txid.clone(),
2144 topic: topic.clone(),
2145 };
2146 match stored(bound, self.storage.insert_applied_transaction(&tx_record)).await {
2147 Ok(()) => {
2148 report.applied_topics.push(topic.clone());
2149 self.not_landed
2150 .borrow_mut()
2151 .remove(&(txid.clone(), topic.clone()));
2152 }
2153 Err(e) => {
2154 error!("Error inserting applied transaction for topic {topic}: {e}");
2155 report.fault(topic, "insert_applied_transaction", e.to_string());
2156 self.not_landed
2157 .borrow_mut()
2158 .insert((txid.clone(), topic.clone()));
2159 }
2160 }
2161 }
2162
2163 Ok((steak, report, blocked))
2164 }
2165
2166 #[allow(clippy::too_many_arguments)]
2218 async fn successors_waiting(
2219 &self,
2220 tx: &Transaction,
2221 txid: &str,
2222 subject_named_beef: &[u8],
2223 validations: &mut [TopicValidation],
2224 steak: &mut Steak,
2225 off_chain_values: Option<&[u8]>,
2226 mode: SubmitMode,
2227 bound: Option<&CallBound>,
2228 finalize: bool,
2229 lands: bool,
2230 blocked: &mut HashMap<String, String>,
2231 question_reads: &mut usize,
2232 landed: &mut Vec<(String, String)>,
2233 landing: Landing,
2234 landed_breaches: &mut Vec<WalkCouldNotRun>,
2235 ) -> HashMap<String, String> {
2236 let mut waits = HashMap::new();
2237 let spent: Vec<(String, u32)> = tx
2238 .inputs
2239 .iter()
2240 .filter_map(|input| {
2241 let source = input.get_source_txid().ok()?;
2242 (!source.is_empty()).then_some((source, input.source_output_index))
2243 })
2244 .collect();
2245 if spent.is_empty() {
2246 return waits;
2247 }
2248 let found_no_coin = |v: &TopicValidation| {
2249 !v.is_dupe && !v.failed && v.read_fault.is_none() && v.previous_outputs.is_empty()
2250 };
2251 for v in validations.iter_mut() {
2252 if !found_no_coin(v) {
2253 continue;
2254 }
2255 let Some(manager) = self.managers.get(&v.topic) else {
2256 continue;
2257 };
2258 let topic = v.topic.clone();
2259 let mut subject_names: Option<HashSet<String>> = None;
2263 loop {
2264 let admits = !v.admittance.outputs_to_admit.is_empty();
2265 if admits && subject_names.is_none() {
2266 subject_names = Some(
2267 self.named_history(manager.as_ref(), subject_named_beef, bound)
2268 .await,
2269 );
2270 }
2271 let remembered = spent.iter().find(|(source, vout)| {
2272 (!admits
2273 || subject_names
2274 .as_ref()
2275 .is_some_and(|named| named.contains(&format!("{source}.{vout}"))))
2276 && self
2277 .not_landed
2278 .borrow()
2279 .contains(&(source.clone(), topic.clone()))
2280 });
2281 if let Some((predecessor, _)) = remembered {
2282 waits.insert(topic.clone(), format!("{predecessor} has not landed"));
2283 break;
2284 }
2285 if finalize {
2286 break;
2287 }
2288 let Some(not_now) = self
2289 .unlanded_predecessor(
2290 tx,
2291 txid,
2292 subject_named_beef,
2293 &topic,
2294 mode,
2295 admits,
2296 &mut subject_names,
2297 question_reads,
2298 bound,
2299 )
2300 .await
2301 else {
2302 break;
2303 };
2304 let Some(carried) = not_now.carried else {
2305 waits.insert(topic.clone(), not_now.why);
2306 break;
2307 };
2308 if !lands {
2309 blocked.insert(topic.clone(), carried);
2310 waits.insert(topic.clone(), not_now.why);
2311 break;
2312 }
2313 if !self.lands_without_values(&topic) {
2314 waits.insert(
2315 topic.clone(),
2316 format!(
2317 "{}; not landed first from the BEEF: the topic's manager or a lookup service reads off-chain values, and only the predecessor's own submit carries them",
2318 not_now.why
2319 ),
2320 );
2321 break;
2322 }
2323 let rejudge = 1 + spent.len();
2325 if *question_reads + rejudge > PREDECESSOR_READS_PER_SUBMIT {
2326 waits.insert(
2327 topic.clone(),
2328 format!(
2329 "{}; its landing and the re-judgement after it do not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads",
2330 not_now.why
2331 ),
2332 );
2333 break;
2334 }
2335 *question_reads += rejudge;
2336 match self
2337 .land_carried(
2338 subject_named_beef,
2339 txid,
2340 &carried,
2341 &topic,
2342 mode,
2343 bound,
2344 question_reads,
2345 landed,
2346 landing,
2347 landed_breaches,
2348 )
2349 .await
2350 {
2351 Ok(()) => {
2352 *v = self
2353 .validate_topic(
2354 tx,
2355 txid,
2356 &topic,
2357 off_chain_values,
2358 mode,
2359 &TopicAdmittanceContext::default(),
2360 bound,
2361 )
2362 .await;
2363 steak.insert(topic.clone(), v.admittance.clone());
2364 if !found_no_coin(v) {
2365 break;
2366 }
2367 }
2368 Err(did_not_land) => {
2369 *question_reads -= rejudge;
2371 waits.insert(
2372 topic.clone(),
2373 format!(
2374 "{}; submitted first from the BEEF, it did not land ({did_not_land})",
2375 not_now.why
2376 ),
2377 );
2378 break;
2379 }
2380 }
2381 }
2382 if !finalize
2391 && waits.contains_key(&topic)
2392 && *question_reads < PREDECESSOR_READS_PER_SUBMIT
2393 {
2394 *question_reads += 1;
2395 if let Ok(outputs) = stored(
2396 bound,
2397 self.storage.find_outputs_for_transaction(txid, false),
2398 )
2399 .await
2400 {
2401 if outputs.iter().any(|o| o.topic == topic) {
2402 info!("topic {topic}: {txid} holds its outputs already; its replay finishes it");
2403 waits.remove(&topic);
2404 blocked.remove(&topic);
2405 }
2406 }
2407 }
2408 }
2409 waits
2410 }
2411
2412 #[allow(clippy::too_many_arguments)]
2445 async fn land_carried(
2446 &self,
2447 beef_bytes: &[u8],
2448 subject: &str,
2449 predecessor: &str,
2450 topic: &str,
2451 mode: SubmitMode,
2452 bound: Option<&CallBound>,
2453 question_reads: &mut usize,
2454 landed: &mut Vec<(String, String)>,
2455 landing: Landing,
2456 landed_breaches: &mut Vec<WalkCouldNotRun>,
2457 ) -> Result<(), String> {
2458 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
2459 .map_err(|e| format!("the BEEF could not be read again ({e})"))?;
2460 let mode = match mode {
2461 SubmitMode::CurrentTx => SubmitMode::HistoricalTx,
2462 other => other,
2463 };
2464 let walks = mode != SubmitMode::HistoricalTxNoSpv;
2465 let mut walked: HashSet<String> = if walks && landing.subject_walk_ran {
2470 walk_cover(&beef, subject)
2471 } else {
2472 HashSet::new()
2473 };
2474 let mut stack = vec![predecessor.to_string()];
2475 let mut guarded: HashSet<String> = HashSet::new();
2476 while let Some(next) = stack.last().cloned() {
2477 let body = beef
2478 .find_atomic_transaction(&next)
2479 .ok_or_else(|| format!("{next}: its body is not in the BEEF"))?;
2480 if let Some(guard) = &self.landing_guard {
2483 if guarded.insert(next.clone()) {
2484 bounded(bound, guard(&next, topic))
2485 .await
2486 .and_then(|answer| answer)
2487 .map_err(|why| format!("{next}: refused by the landing guard ({why})"))?;
2488 }
2489 }
2490 let cost = 1 + 2 * body.inputs.len();
2493 if *question_reads + cost > PREDECESSOR_READS_PER_SUBMIT {
2494 return Err(format!(
2495 "{next}: its submit does not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2496 ));
2497 }
2498 *question_reads += cost;
2499 let atomic = body
2500 .to_atomic_beef(true)
2501 .map_err(|e| format!("{next}: its BEEF could not be built ({e})"))?;
2502 let tagged = TaggedBEEF::new(atomic, vec![topic.to_string()]);
2503 let (_, report, blocked) = match self
2504 .submit_counted(
2505 &tagged,
2506 mode,
2507 bound,
2508 false,
2509 Some(&walked),
2510 question_reads,
2511 landing.policy,
2512 )
2513 .await
2514 {
2515 Ok(counted) => counted,
2516 Err(EngineError::WalkCouldNotRun(stop)) => {
2519 let why = format!("{next}: {}", EngineError::WalkCouldNotRun(stop.clone()));
2520 landed_breaches.push(stop);
2521 return Err(why);
2522 }
2523 Err(e) => return Err(format!("{next}: {e}")),
2524 };
2525 if let Some(stop) = &report.walk_could_not_run {
2528 landed_breaches.push(stop.clone());
2529 } else if walks && !walked.contains(&next) {
2530 walked.extend(walk_cover(&beef, &next));
2531 }
2532 let landed_now = report.applied_topics.iter().any(|t| t == topic);
2533 if report.is_durable()
2534 && (landed_now || report.deduped_topics.iter().any(|t| t == topic))
2535 {
2536 info!("topic {topic}: {next} landed first from its successor's BEEF");
2537 if landed_now {
2538 landed.push((next.clone(), topic.to_string()));
2539 }
2540 stack.pop();
2541 continue;
2542 }
2543 match blocked.get(topic) {
2544 Some(under) if !stack.contains(under) => stack.push(under.clone()),
2545 _ if report.is_durable() => return Err(format!("{next}: the manager failed it")),
2546 _ => return Err(format!("{next}: {}", report.summary())),
2547 }
2548 }
2549 Ok(())
2550 }
2551
2552 fn hold_unapplied(&self, txid: &str, topic: &str, why: &str) {
2555 warn!("topic {topic}: {txid}: {why}; applied_transactions NOT recorded (bsv-low #559)");
2556 self.not_landed
2557 .borrow_mut()
2558 .insert((txid.to_string(), topic.to_string()));
2559 }
2560
2561 async fn undo_inserts(
2569 &self,
2570 txid: &str,
2571 topic: &str,
2572 output_indices: &[u32],
2573 bound: Option<&CallBound>,
2574 report: &mut MutationReport,
2575 ) {
2576 for &output_index in output_indices {
2577 let row = stored(
2578 bound,
2579 self.storage
2580 .find_output(txid, output_index, Some(topic), None, false),
2581 )
2582 .await;
2583 let undone = match row {
2584 Ok(Some(row)) if !row.spent && row.consumed_by.is_empty() => {
2585 stored(bound, self.storage.delete_output(txid, output_index, topic)).await
2586 }
2587 Ok(_) => Ok(()),
2588 Err(e) => Err(e),
2589 };
2590 if let Err(e) = undone {
2591 error!("Error taking {txid}.{output_index} out of topic {topic} again: {e}");
2592 report.fault(topic, "undo_insert_output", e.to_string());
2593 }
2594 }
2595 }
2596
2597 #[allow(clippy::too_many_arguments)]
2713 async fn unlanded_predecessor(
2714 &self,
2715 tx: &Transaction,
2716 txid: &str,
2717 beef_bytes: &[u8],
2718 topic: &str,
2719 mode: SubmitMode,
2720 admits: bool,
2721 subject_names: &mut Option<HashSet<String>>,
2722 question_reads: &mut usize,
2723 bound: Option<&CallBound>,
2724 ) -> Option<NotNow> {
2725 let manager = self.managers.get(topic)?;
2726 let wait = |why: String| Some(NotNow { why, carried: None });
2727 let starts: Vec<(String, u32)> = tx
2729 .inputs
2730 .iter()
2731 .filter_map(|input| {
2732 let source = input.get_source_txid().ok()?;
2733 (!source.is_empty()).then_some((source, input.source_output_index))
2734 })
2735 .collect();
2736 let mut spent_of: HashMap<String, HashSet<u32>> = HashMap::new();
2739 let mut spenders: HashMap<String, Vec<(String, u32)>> = HashMap::new();
2740 let mut ask: Vec<String> = Vec::new();
2741 for (source, vout) in &starts {
2742 if admits
2743 && subject_names
2744 .as_ref()
2745 .is_some_and(|named| !named.contains(&format!("{source}.{vout}")))
2746 {
2747 continue;
2748 }
2749 spent_of.entry(source.clone()).or_default().insert(*vout);
2750 spenders
2751 .entry(source.clone())
2752 .or_default()
2753 .push((txid.to_string(), *vout));
2754 ask.push(source.clone());
2755 }
2756 if ask.is_empty() {
2759 return None;
2760 }
2761 let direct: HashSet<String> = ask.iter().cloned().collect();
2762 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS).ok()?;
2763 let bodies: HashMap<String, (&Transaction, bool)> = beef
2766 .txs
2767 .iter()
2768 .filter_map(|btx| {
2769 btx.tx()
2770 .map(|body| (btx.txid(), (body, btx.bump_index().is_some())))
2771 })
2772 .collect();
2773 let mut names_over: HashMap<String, HashSet<String>> = HashMap::new();
2775 let mut reads = 0usize;
2777 let mut asked: HashSet<String> = HashSet::new();
2778 while let Some(candidate) = ask.pop() {
2779 if candidate.is_empty() || !asked.insert(candidate.clone()) {
2780 continue;
2781 }
2782 let spent_outputs = spent_of.get(&candidate).cloned().unwrap_or_default();
2783 let body = bodies.get(&candidate).copied();
2786 if body.is_none() {
2787 if !direct.contains(&candidate) {
2788 continue;
2789 }
2790 if subject_names.is_none() {
2791 *subject_names = Some(
2792 self.named_history(manager.as_ref(), beef_bytes, bound)
2793 .await,
2794 );
2795 }
2796 let is_named = subject_names.as_ref().is_some_and(|named| {
2797 spent_outputs
2798 .iter()
2799 .any(|vout| named.contains(&format!("{candidate}.{vout}")))
2800 });
2801 if !is_named {
2802 continue;
2803 }
2804 }
2805 let proven = body.is_some_and(|(_, proven)| proven);
2806 let mut spent = 0usize;
2809 let out_of = |spent: usize| !proven && reads + spent >= PREDECESSOR_READS;
2810 let out_of_reads = || {
2811 wait(format!(
2812 "{candidate} is not known to have landed: the question ran out of its {PREDECESSOR_READS} reads"
2813 ))
2814 };
2815 let out_of_allowance = || {
2818 wait(format!(
2819 "{candidate} is not known to have landed: the question ran out of the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2820 ))
2821 };
2822 let unread = |e: StorageError| {
2823 wait(format!(
2824 "{candidate} is not known to have landed: the store could not say ({e})"
2825 ))
2826 };
2827 let carried = |why: String| {
2828 Some(NotNow {
2829 why,
2830 carried: Some(candidate.clone()),
2831 })
2832 };
2833 let record = AppliedTransaction {
2834 txid: candidate.clone(),
2835 topic: topic.to_string(),
2836 };
2837 if out_of(spent) {
2840 return out_of_reads();
2841 }
2842 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2843 return out_of_allowance();
2844 }
2845 spent += 1;
2846 *question_reads += 1;
2847 match stored(bound, self.storage.does_applied_transaction_exist(&record)).await {
2848 Ok(false) => {}
2849 Ok(true) => continue,
2850 Err(e) => return unread(e),
2851 }
2852 if out_of(spent) {
2853 return out_of_reads();
2854 }
2855 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2856 return out_of_allowance();
2857 }
2858 spent += 1;
2859 *question_reads += 1;
2860 match stored(
2861 bound,
2862 self.storage.find_outputs_for_transaction(&candidate, false),
2863 )
2864 .await
2865 {
2866 Ok(outputs) if outputs.iter().all(|o| o.topic != topic) => {}
2867 Ok(_) => continue,
2868 Err(e) => return unread(e),
2869 }
2870 let Some((body, _)) = body else {
2873 return wait(format!(
2874 "{candidate} has not landed (the manager names it as history; the BEEF does not carry it)"
2875 ));
2876 };
2877 for input in &body.inputs {
2878 let source = input.get_source_txid().unwrap_or_default();
2879 if source.is_empty() {
2880 continue;
2881 }
2882 if out_of(spent) {
2883 return out_of_reads();
2884 }
2885 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2886 return out_of_allowance();
2887 }
2888 spent += 1;
2889 *question_reads += 1;
2890 let coin = stored(
2891 bound,
2892 self.storage.find_output(
2893 &source,
2894 input.source_output_index,
2895 Some(topic),
2896 None,
2897 false,
2898 ),
2899 )
2900 .await;
2901 match coin {
2902 Ok(Some(_)) => return carried(format!("{candidate} has not landed")),
2903 Ok(None) => {
2906 spent_of
2907 .entry(source.clone())
2908 .or_default()
2909 .insert(input.source_output_index);
2910 spenders
2911 .entry(source.clone())
2912 .or_default()
2913 .push((candidate.clone(), input.source_output_index));
2914 ask.push(source);
2915 }
2916 Err(e) => return unread(e),
2917 }
2918 }
2919 let mut named_outputs: HashSet<u32> = HashSet::new();
2923 for (spender, vout) in spenders.get(&candidate).cloned().unwrap_or_default() {
2924 let names = if spender == txid {
2925 if subject_names.is_none() {
2926 *subject_names = Some(
2927 self.named_history(manager.as_ref(), beef_bytes, bound)
2928 .await,
2929 );
2930 }
2931 subject_names.as_ref()
2932 } else {
2933 if !names_over.contains_key(&spender) {
2934 let names = match beef
2935 .find_atomic_transaction(&spender)
2936 .and_then(|spender| spender.to_atomic_beef(true).ok())
2937 {
2938 Some(spender_beef) => {
2939 self.named_history(manager.as_ref(), &spender_beef, bound)
2940 .await
2941 }
2942 None => HashSet::new(),
2943 };
2944 names_over.insert(spender.clone(), names);
2945 }
2946 names_over.get(&spender)
2947 };
2948 if names.is_some_and(|named| named.contains(&format!("{candidate}.{vout}"))) {
2949 named_outputs.insert(vout);
2950 }
2951 }
2952 if !named_outputs.is_empty() {
2953 let judged = beef
2958 .find_atomic_transaction(&candidate)
2959 .unwrap_or_else(|| body.clone());
2960 let verdict = bounded(
2961 bound,
2962 manager.identify_admissible_outputs(
2963 &judged,
2964 &[],
2965 None,
2966 mode,
2967 &TopicAdmittanceContext::DRY_RUN,
2968 ),
2969 )
2970 .await;
2971 match verdict {
2972 Ok(Ok(admittance)) => {
2973 if admittance
2974 .outputs_to_admit
2975 .iter()
2976 .any(|vout| named_outputs.contains(vout))
2977 {
2978 return carried(format!(
2979 "{candidate} has not landed (the manager names the output spent from it and would admit it with no coin: an opener)"
2980 ));
2981 }
2982 }
2983 Ok(Err(TopicManagerError::NoAdmissibleOutputs(_))) => {}
2984 Ok(Err(e)) => {
2985 return wait(format!(
2986 "{candidate} is not known to have landed: the manager could not judge it ({e})"
2987 ))
2988 }
2989 Err(no_answer) => {
2990 return wait(format!(
2991 "{candidate} is not known to have landed: the manager did not answer ({no_answer})"
2992 ))
2993 }
2994 }
2995 }
2996 if !proven {
2997 reads += spent;
2998 }
2999 }
3000 None
3001 }
3002
3003 async fn named_history(
3009 &self,
3010 manager: &dyn TopicManager,
3011 beef_bytes: &[u8],
3012 bound: Option<&CallBound>,
3013 ) -> HashSet<String> {
3014 match bounded(bound, manager.identify_needed_inputs(beef_bytes, None)).await {
3015 Ok(Ok(named)) => named.iter().map(Outpoint::to_graph_id).collect(),
3016 Ok(Err(e)) => {
3017 warn!("the predecessor question: the manager could not name its inputs: {e}");
3018 HashSet::new()
3019 }
3020 Err(no_answer) => {
3021 warn!("the predecessor question: the manager did not name its inputs: {no_answer}");
3022 HashSet::new()
3023 }
3024 }
3025 }
3026
3027 async fn verify_beef_linear(
3062 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3063 beef_bytes: &[u8],
3064 subject_txid: &str,
3065 trusted: &HashSet<String>,
3066 budget: DoorBudget,
3067 ) -> Result<Option<WalkCouldNotRun>, EngineError> {
3068 let spv =
3070 |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3071
3072 let mut trace = crate::script_door::WalkTrace::default();
3077 match crate::script_door::walk_trusting(
3078 beef_bytes,
3079 subject_txid,
3080 budget,
3081 trusted,
3082 &mut trace,
3083 ) {
3084 Ok(_) => {}
3085 Err(refused @ EngineError::ScriptVerificationFailed { .. }) => return Err(refused),
3087 Err(EngineError::ScriptWalkInconclusive { reason, .. }) => return Err(spv(reason)),
3090 Err(EngineError::ScriptWalkOverBudget {
3099 at_txid,
3100 subject_judged,
3101 limb,
3102 what,
3103 }) => {
3104 let limb = match limb {
3105 DoorLimb::Memory => WalkLimb::OverMemory,
3106 DoorLimb::Work if trace.interpreter_tripped => WalkLimb::InterpreterMemory,
3107 DoorLimb::Work => WalkLimb::OverWork,
3108 };
3109 let reached: Vec<String> = trace
3117 .proven
3118 .iter()
3119 .chain(&trace.proven_sources)
3120 .cloned()
3121 .collect();
3122 Self::check_reached_roots(chain_tracker, beef_bytes, &reached).await?;
3123 return Ok(Some(WalkCouldNotRun {
3124 subject_txid: subject_txid.to_string(),
3125 at_txid,
3126 subject_judged,
3127 limb,
3128 what,
3129 }));
3130 }
3131 Err(other) => return Err(other),
3132 }
3133
3134 Self::check_reached_roots(chain_tracker, beef_bytes, &trace.proven).await?;
3141 Ok(None)
3142 }
3143
3144 async fn check_reached_roots(
3148 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3149 beef_bytes: &[u8],
3150 reached: &[String],
3151 ) -> Result<(), EngineError> {
3152 let spv =
3153 |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3154 if reached.is_empty() {
3155 return Ok(());
3156 }
3157 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
3158 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3159 let proven_in = proven_index(&beef);
3160 let mut checked: HashSet<usize> = HashSet::new();
3161 for txid in reached {
3162 let Some(&at) = proven_in.get(txid.as_str()) else {
3163 return Err(spv(format!("transaction {txid} is not in the BEEF")));
3164 };
3165 if !checked.insert(at) {
3166 continue; }
3168 let mp = &beef.bumps[at];
3169 let root = mp
3170 .compute_root(Some(txid))
3171 .map_err(|e| spv(format!("invalid merkle path for transaction {txid}: {e}")))?;
3172 if let Some(tracker) = chain_tracker {
3173 match tracker
3174 .is_valid_root_for_height(&root, mp.block_height)
3175 .await
3176 {
3177 Ok(true) => {}
3178 Ok(false) => {
3179 return Err(EngineError::SpvError(format!(
3180 "Invalid merkle path for transaction {txid}: root {root} is not valid for block height {}",
3181 mp.block_height
3182 )));
3183 }
3184 Err(e) => {
3185 return Err(EngineError::SpvError(format!(
3186 "Chain tracker error at height {}: {e}",
3187 mp.block_height
3188 )));
3189 }
3190 }
3191 }
3192 }
3193 Ok(())
3194 }
3195
3196 async fn verify_spv_structurally(
3210 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3211 beef_bytes: &[u8],
3212 ) -> Result<(), EngineError> {
3213 let Some(chain_tracker) = chain_tracker else {
3214 return Ok(());
3215 };
3216 let roots = beef_limits::structure_roots(beef_bytes)
3217 .map_err(|r| EngineError::SpvError(format!("BEEF structure refused: {r}")))?;
3218 let mut asked: HashSet<(u64, &str)> = HashSet::new();
3219 for (height, root) in &roots {
3220 if !asked.insert((*height, root.as_str())) {
3221 continue;
3222 }
3223 let Ok(height) = u32::try_from(*height) else {
3224 return Err(EngineError::SpvError(format!(
3225 "Merkle root {root} invalid for block height {height}"
3226 )));
3227 };
3228 match chain_tracker.is_valid_root_for_height(root, height).await {
3229 Ok(true) => {}
3230 Ok(false) => {
3231 return Err(EngineError::SpvError(format!(
3232 "Merkle root {root} invalid for block height {height}"
3233 )));
3234 }
3235 Err(e) => {
3236 return Err(EngineError::SpvError(format!(
3237 "Chain tracker error at height {height}: {e}"
3238 )));
3239 }
3240 }
3241 }
3242 Ok(())
3243 }
3244
3245 #[allow(clippy::too_many_arguments)]
3250 async fn validate_topic(
3251 &self,
3252 tx: &Transaction,
3253 txid: &str,
3254 topic: &str,
3255 off_chain_values: Option<&[u8]>,
3256 mode: SubmitMode,
3257 context: &TopicAdmittanceContext,
3258 bound: Option<&CallBound>,
3259 ) -> TopicValidation {
3260 let tx_record = AppliedTransaction {
3261 txid: txid.to_string(),
3262 topic: topic.to_string(),
3263 };
3264 let is_dupe = match stored(
3274 bound,
3275 self.storage.does_applied_transaction_exist(&tx_record),
3276 )
3277 .await
3278 {
3279 Ok(is_dupe) => is_dupe,
3280 Err(e) => {
3281 error!("Error reading the applied row of {txid} for topic {topic}: {e}");
3282 return TopicValidation {
3283 topic: topic.to_string(),
3284 is_dupe: false,
3285 previous_coins: vec![],
3286 previous_outputs: vec![],
3287 admittance: AdmittanceInstructions::default(),
3288 failed: false,
3289 read_fault: Some(("does_applied_transaction_exist", e.to_string())),
3290 };
3291 }
3292 };
3293
3294 if is_dupe {
3295 return TopicValidation {
3296 topic: topic.to_string(),
3297 is_dupe: true,
3298 previous_coins: vec![],
3299 previous_outputs: vec![],
3300 admittance: AdmittanceInstructions::default(),
3301 failed: false,
3302 read_fault: None,
3303 };
3304 }
3305
3306 let mut previous_coins: Vec<u32> = Vec::new();
3307 let mut previous_outputs: Vec<Output> = Vec::new();
3308 let mut read_fault: Option<(&'static str, String)> = None;
3309
3310 for (input_idx, input) in tx.inputs.iter().enumerate() {
3311 let source_txid = input.get_source_txid().unwrap_or_default();
3312 if source_txid.is_empty() {
3313 continue;
3314 }
3315 match stored(
3316 bound,
3317 self.storage.find_output(
3318 &source_txid,
3319 input.source_output_index,
3320 Some(topic),
3321 None,
3322 false,
3323 ),
3324 )
3325 .await
3326 {
3327 Ok(Some(prev_output)) => {
3328 previous_coins.push(input_idx as u32);
3329 previous_outputs.push(prev_output);
3330 }
3331 Ok(None) => {}
3332 Err(e) => {
3333 error!(
3339 "Error reading previous output {source_txid}:{} for topic {topic}: {e}",
3340 input.source_output_index
3341 );
3342 if read_fault.is_none() {
3343 read_fault = Some(("find_output", e.to_string()));
3344 }
3345 }
3346 }
3347 }
3348
3349 let judged = self
3350 .judge(
3351 tx,
3352 topic,
3353 &previous_coins,
3354 off_chain_values,
3355 mode,
3356 context,
3357 bound,
3358 )
3359 .await;
3360 match judged {
3361 Ok(admittance) => TopicValidation {
3362 topic: topic.to_string(),
3363 is_dupe: false,
3364 previous_coins,
3365 previous_outputs,
3366 admittance,
3367 failed: false,
3368 read_fault: read_fault.clone(),
3369 },
3370 Err(e) => {
3371 error!("Error validating topic {topic} during submit: {e}");
3372 TopicValidation {
3373 topic: topic.to_string(),
3374 is_dupe: false,
3375 previous_coins: vec![],
3376 previous_outputs: vec![],
3377 admittance: AdmittanceInstructions::default(),
3378 failed: true,
3379 read_fault,
3380 }
3381 }
3382 }
3383 }
3384
3385 #[allow(clippy::too_many_arguments)]
3390 async fn judge(
3391 &self,
3392 tx: &Transaction,
3393 topic: &str,
3394 previous_coins: &[u32],
3395 off_chain_values: Option<&[u8]>,
3396 mode: SubmitMode,
3397 context: &TopicAdmittanceContext,
3398 bound: Option<&CallBound>,
3399 ) -> Result<AdmittanceInstructions, TopicManagerError> {
3400 let manager = &self.managers[topic];
3401 let previous_coin_bytes = previous_coins
3402 .iter()
3403 .flat_map(|i| i.to_le_bytes())
3404 .collect::<Vec<u8>>();
3405 bounded(
3408 bound,
3409 manager.identify_admissible_outputs(
3410 tx,
3414 &previous_coin_bytes,
3415 off_chain_values,
3416 mode,
3417 context,
3420 ),
3421 )
3422 .await
3423 .map_err(TopicManagerError::Other)
3424 .and_then(|judged| judged)
3425 }
3426
3427 fn lands_without_values(&self, topic: &str) -> bool {
3431 self.managers
3432 .get(topic)
3433 .is_some_and(|manager| !manager.reads_off_chain_values())
3434 && self
3435 .lookup_services
3436 .values()
3437 .all(|ls| !ls.reads_off_chain_values(topic))
3438 }
3439
3440 async fn run_validation(
3445 &self,
3446 tagged_beef: &TaggedBEEF,
3447 mode: SubmitMode,
3448 context: &TopicAdmittanceContext,
3449 bound: Option<&CallBound>,
3450 walked: Option<&HashSet<String>>,
3451 policy: WalkBreachPolicy,
3452 ) -> Result<Validated, EngineError> {
3453 for topic in &tagged_beef.topics {
3455 if !self.managers.contains_key(topic) {
3456 return Err(EngineError::UnsupportedTopic(topic.clone()));
3457 }
3458 }
3459
3460 let subject = {
3466 let mut b =
3467 beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
3468 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3469 crate::subject::subject_txid_of(&mut b)
3470 };
3471 let tx = beef_limits::transaction_from_beef(
3472 &tagged_beef.beef,
3473 subject.as_deref(),
3474 &beef_limits::ENGINE_BEEF_LIMITS,
3475 )
3476 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3477 let txid = tx.id();
3478
3479 let mut could_not_run = None;
3502 if mode != SubmitMode::HistoricalTxNoSpv {
3503 match walked {
3504 Some(walked) if walked.contains(&txid) => {}
3505 trusted => {
3506 could_not_run = verify_spv_trusting(
3507 self.chain_tracker.as_deref(),
3508 self.verify_scripts,
3509 &tagged_beef.beef,
3510 &txid,
3511 trusted.unwrap_or(&HashSet::new()),
3512 self.walk_budget,
3513 )
3514 .await?;
3515 }
3516 }
3517 }
3518 if let Some(stop) = could_not_run.take() {
3519 match policy {
3520 WalkBreachPolicy::NotNow => {
3521 warn!(
3522 "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): not now, nothing judged or written; present it proven (bsv-low #592, E592 fold)",
3523 stop.subject_txid,
3524 stop.limb.as_str(),
3525 stop.at_txid,
3526 stop.subject_judged,
3527 stop.what
3528 );
3529 return Err(EngineError::WalkCouldNotRun(stop));
3530 }
3531 WalkBreachPolicy::NetworkAccepted => {
3532 warn!(
3533 "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): the network accepted these bytes, the submit goes on as historical-tx-no-spv (bsv-low #592)",
3534 stop.subject_txid,
3535 stop.limb.as_str(),
3536 stop.at_txid,
3537 stop.subject_judged,
3538 stop.what
3539 );
3540 could_not_run = Some(stop);
3541 }
3542 }
3543 }
3544
3545 let mut steak = Steak::new();
3546
3547 let mut validations = Vec::new();
3551
3552 for topic in &tagged_beef.topics {
3553 validations.push(
3554 self.validate_topic(
3555 &tx,
3556 &txid,
3557 topic,
3558 tagged_beef.off_chain_values.as_deref(),
3559 mode,
3560 context,
3561 bound,
3562 )
3563 .await,
3564 );
3565 }
3566
3567 for v in &validations {
3569 steak.insert(v.topic.clone(), v.admittance.clone());
3570 }
3571
3572 if mode == SubmitMode::CurrentTx {
3576 let all_topics_dupe = !validations.is_empty() && validations.iter().all(|v| v.is_dupe);
3591 if let Some(ref arc) = self.arc_broadcaster {
3592 if all_topics_dupe {
3593 info!(
3594 "Skipping ARC broadcast — tx already applied to every topic (re-present)"
3595 );
3596 } else if tx.merkle_path.is_none() {
3597 let raw_tx_hex = tx.to_hex();
3598 match arc.broadcast(&raw_tx_hex).await {
3599 Ok(arc_txid) => {
3600 info!("ARC broadcast succeeded: txid={arc_txid}");
3601 }
3602 Err(e) => {
3603 error!("ARC broadcast failed (non-fatal): {e}");
3604 }
3605 }
3606 } else {
3607 info!("Skipping ARC broadcast — tx already has merkle proof");
3608 }
3609 }
3610
3611 if let Some(ref broadcaster) = self.broadcaster {
3613 let relevant_topics: Vec<String> = validations
3614 .iter()
3615 .filter(|v| {
3616 !v.is_dupe && !v.failed && !v.admittance.outputs_to_admit.is_empty()
3617 })
3618 .map(|v| v.topic.clone())
3619 .collect();
3620
3621 if !relevant_topics.is_empty() {
3622 if let Some(ship_ls) = self.lookup_services.get("ls_ship") {
3623 for topic in &relevant_topics {
3624 let question = LookupQuestion::new(
3625 "ls_ship",
3626 serde_json::json!({ "topics": [topic] }),
3627 );
3628 match ship_ls.lookup(&question).await {
3629 Ok(LookupResult::OutputList(refs)) => {
3630 for reference in &refs {
3631 if let Ok(Some(output)) = self
3632 .storage
3633 .find_output(
3634 &reference.txid,
3635 reference.output_index,
3636 None,
3637 None,
3638 false,
3639 )
3640 .await
3641 {
3642 if let Some(domain) =
3643 parse_ship_domain_from_script(&output.output_script)
3644 {
3645 if let Some(ref our_url) = self.config.hosting_url {
3646 if domain.trim_end_matches('/')
3647 == our_url.trim_end_matches('/')
3648 {
3649 continue;
3650 }
3651 }
3652 if let Err(e) = broadcaster
3653 .broadcast_to_host(&domain, tagged_beef)
3654 .await
3655 {
3656 error!(
3657 "SHIP propagation to {domain} failed for topic {topic}: {e}"
3658 );
3659 }
3660 }
3661 }
3662 }
3663 }
3664 Ok(LookupResult::Answer(_)) => {
3665 error!(
3669 "SHIP lookup for topic {topic} returned a pre-formed \
3670 LookupAnswer; expected OutputList. Skipping fanout."
3671 );
3672 }
3673 Err(e) => {
3674 error!("SHIP lookup for topic {topic} failed: {e}");
3675 }
3676 }
3677 }
3678 }
3679 }
3680 }
3681 }
3682
3683 Ok((validations, steak, tx, txid, could_not_run))
3684 }
3685
3686 pub async fn lookup(
3698 &self,
3699 question: &LookupQuestion,
3700 history_selector: Option<HistorySelector>,
3701 ) -> Result<LookupAnswer, EngineError> {
3702 Ok(self.lookup_with_txids(question, history_selector).await?.0)
3703 }
3704
3705 pub async fn lookup_with_txids(
3714 &self,
3715 question: &LookupQuestion,
3716 history_selector: Option<HistorySelector>,
3717 ) -> Result<(LookupAnswer, Vec<String>), EngineError> {
3718 let service = self
3719 .lookup_services
3720 .get(&question.service)
3721 .ok_or_else(|| EngineError::LookupServiceNotFound(question.service.clone()))?;
3722
3723 let result = service.lookup(question).await.map_err(|e| match e {
3728 LookupServiceError::InvalidQuery(m) | LookupServiceError::Unsupported(m) => {
3729 EngineError::InvalidQuery(m)
3730 }
3731 other => EngineError::LookupFailed(other.to_string()),
3732 })?;
3733
3734 let refs = match result {
3743 LookupResult::OutputList(refs) => refs,
3744 LookupResult::Answer(answer) => return Ok((answer, Vec::new())),
3745 };
3746
3747 let outpoints: Vec<Outpoint> = refs
3755 .iter()
3756 .map(|r| Outpoint::new(&r.txid, r.output_index))
3757 .collect();
3758 let found = self
3759 .storage
3760 .find_served_outputs(&outpoints)
3761 .await
3762 .map_err(|e| EngineError::StorageError(e.to_string()))?;
3763
3764 let mut outputs = Vec::new();
3765 let mut txids = Vec::new();
3766 for output in found {
3767 let final_output = if history_selector.is_some() {
3769 match self
3770 .get_utxo_history(&output, history_selector.clone())
3771 .await
3772 {
3773 Ok(Some(hydrated)) => hydrated,
3774 _ => output,
3775 }
3776 } else {
3777 output
3778 };
3779
3780 if let Some(beef) = final_output.beef {
3787 if !beef.is_empty() {
3788 txids.push(final_output.txid.clone());
3789 outputs.push(OutputListItem {
3790 beef,
3791 output_index: final_output.output_index,
3792 context: None,
3793 });
3794 }
3795 }
3796 }
3797
3798 Ok((LookupAnswer::OutputList { outputs }, txids))
3799 }
3800
3801 pub async fn get_utxo_history(
3815 &self,
3816 output: &Output,
3817 history_selector: Option<HistorySelector>,
3818 ) -> Result<Option<Output>, EngineError> {
3819 let Some(selector) = history_selector else {
3820 return Ok(Some(output.clone()));
3821 };
3822
3823 if output.beef.is_none() {
3825 return Err(EngineError::Other(
3826 "Output must have associated transaction BEEF!".into(),
3827 ));
3828 }
3829
3830 match self.hydrate_utxo_history(output, &selector, 0).await {
3831 Ok(Some(hydrated_output)) => Ok(Some(hydrated_output)),
3832 Ok(None) => Ok(None),
3833 Err(e) => Err(EngineError::Other(format!(
3834 "Error retrieving UTXO history: {e}"
3835 ))),
3836 }
3837 }
3838
3839 fn hydrate_utxo_history<'a>(
3841 &'a self,
3842 output: &'a Output,
3843 selector: &'a HistorySelector,
3844 current_depth: u32,
3845 ) -> std::pin::Pin<
3846 Box<dyn std::future::Future<Output = Result<Option<Output>, EngineError>> + 'a>,
3847 > {
3848 Box::pin(async move {
3849 let beef_data = output.beef.as_ref().ok_or_else(|| {
3850 EngineError::Other("Output must have associated transaction BEEF!".into())
3851 })?;
3852
3853 let should_traverse = match selector {
3855 HistorySelector::Depth(max_depth) => current_depth <= *max_depth,
3856 };
3857
3858 if !should_traverse {
3859 return Ok(None);
3860 }
3861
3862 let mut acc = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3877 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3878
3879 for consumed in &output.outputs_consumed {
3881 if let Ok(Some(child_output)) = self
3882 .storage
3883 .find_output(&consumed.txid, consumed.output_index, None, None, true)
3884 .await
3885 {
3886 if let Ok(Some(hydrated_child)) = self
3887 .hydrate_utxo_history(&child_output, selector, current_depth + 1)
3888 .await
3889 {
3890 if let Some(ref child_beef) = hydrated_child.beef {
3896 if let Ok(child) = Beef::from_binary(child_beef) {
3897 acc.merge_beef(&child);
3898 }
3899 }
3900 }
3901 }
3902 }
3903
3904 Ok(Some(Output {
3905 beef: Some(acc.to_binary()),
3906 ..output.clone()
3907 }))
3908 })
3909 }
3910
3911 pub async fn list_topic_managers(&self) -> HashMap<String, ServiceMetadata> {
3916 let mut result = HashMap::new();
3917 for (name, manager) in &self.managers {
3918 let meta = manager.get_metadata().await;
3919 result.insert(name.clone(), meta);
3920 }
3921 result
3922 }
3923
3924 pub async fn list_lookup_service_providers(&self) -> HashMap<String, ServiceMetadata> {
3926 let mut result = HashMap::new();
3927 for (name, service) in &self.lookup_services {
3928 let meta = service.get_metadata().await;
3929 result.insert(name.clone(), meta);
3930 }
3931 result
3932 }
3933
3934 pub async fn get_documentation_for_topic_manager(&self, name: &str) -> String {
3936 match self.managers.get(name) {
3937 Some(manager) => manager.get_documentation().await,
3938 None => "No documentation found!".to_string(),
3939 }
3940 }
3941
3942 pub async fn get_documentation_for_lookup_service(&self, name: &str) -> String {
3944 match self.lookup_services.get(name) {
3945 Some(service) => service.get_documentation().await,
3946 None => "No documentation found!".to_string(),
3947 }
3948 }
3949
3950 pub async fn provide_foreign_sync_response(
3968 &self,
3969 request: &GASPInitialRequest,
3970 topic: &str,
3971 ) -> Result<GASPInitialResponse, EngineError> {
3972 let limit = Self::clamp_sync_limit(request.limit);
3973 let outputs = self
3974 .storage
3975 .find_utxos_for_topic(topic, Some(request.since as f64), Some(limit), false)
3976 .await
3977 .map_err(|e| EngineError::StorageError(e.to_string()))?;
3978
3979 Ok(GASPInitialResponse {
3980 utxo_list: outputs
3981 .iter()
3982 .map(|o| GASPOutput {
3983 txid: o.txid.clone(),
3984 output_index: o.output_index,
3985 score: o.score.unwrap_or(0.0),
3986 })
3987 .collect(),
3988 since: request.since,
3989 })
3990 }
3991
3992 pub async fn provide_foreign_gasp_node(
3998 &self,
3999 graph_id: &str,
4000 txid: &str,
4001 output_index: u32,
4002 ) -> Result<GASPNode, EngineError> {
4003 let root_outpoint = Outpoint::from_graph_id(graph_id)
4004 .ok_or_else(|| EngineError::Other(format!("Invalid graphID: {graph_id}")))?;
4005
4006 let root_output = self
4007 .storage
4008 .find_output(
4009 &root_outpoint.txid,
4010 root_outpoint.output_index,
4011 None,
4012 None,
4013 true,
4014 )
4015 .await
4016 .map_err(|e| EngineError::StorageError(e.to_string()))?
4017 .ok_or(EngineError::NodeNotFound)?;
4018
4019 self.hydrate_gasp_node(&root_output, graph_id, txid, output_index)
4020 .await
4021 }
4022
4023 fn hydrate_gasp_node<'a>(
4025 &'a self,
4026 output: &'a Output,
4027 graph_id: &'a str,
4028 txid: &'a str,
4029 output_index: u32,
4030 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GASPNode, EngineError>> + 'a>>
4031 {
4032 Box::pin(async move {
4033 let beef_data = output.beef.as_ref().ok_or(EngineError::NodeNotFound)?;
4034
4035 let root = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
4038 .ok()
4039 .and_then(|mut b| crate::subject::subject_txid_of(&mut b));
4040 let root_tx = beef_limits::transaction_from_beef(
4041 beef_data,
4042 root.as_deref(),
4043 &beef_limits::STORED_BEEF_LIMITS,
4044 )
4045 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
4046
4047 if let Some(node) = Self::search_tx_tree(&root_tx, graph_id, txid, output_index) {
4049 return Ok(node);
4050 }
4051
4052 if let Some(node) = Self::search_beef_for_txid(beef_data, graph_id, txid, output_index)
4055 {
4056 return Ok(node);
4057 }
4058
4059 for consumed in &output.outputs_consumed {
4061 if let Ok(Some(consumed_output)) = self
4062 .storage
4063 .find_output(&consumed.txid, consumed.output_index, None, None, true)
4064 .await
4065 {
4066 if let Ok(node) = self
4067 .hydrate_gasp_node(&consumed_output, graph_id, txid, output_index)
4068 .await
4069 {
4070 return Ok(node);
4071 }
4072 }
4073 }
4074
4075 Err(EngineError::NodeNotFound)
4084 })
4085 }
4086
4087 fn search_tx_tree(
4089 tx: &Transaction,
4090 graph_id: &str,
4091 target_txid: &str,
4092 output_index: u32,
4093 ) -> Option<GASPNode> {
4094 let current_txid = tx.id();
4095
4096 if current_txid == target_txid {
4097 let mut node = GASPNode {
4098 graph_id: graph_id.to_string(),
4099 raw_tx: tx.to_hex(),
4100 output_index,
4101 proof: None,
4102 tx_metadata: None,
4103 output_metadata: None,
4104 inputs: None,
4105 };
4106 if let Some(ref merkle_path) = tx.merkle_path {
4107 node.proof = Some(merkle_path.to_hex());
4108 }
4109 return Some(node);
4110 }
4111
4112 for input in &tx.inputs {
4114 if let Some(ref source_tx) = input.source_transaction {
4115 if let Some(node) =
4116 Self::search_tx_tree(source_tx, graph_id, target_txid, output_index)
4117 {
4118 return Some(node);
4119 }
4120 }
4121 }
4122
4123 None
4124 }
4125
4126 fn search_beef_for_txid(
4130 beef_data: &[u8],
4131 graph_id: &str,
4132 target_txid: &str,
4133 output_index: u32,
4134 ) -> Option<GASPNode> {
4135 if let Ok(tx) = beef_limits::transaction_from_beef(
4137 beef_data,
4138 Some(target_txid),
4139 &beef_limits::STORED_BEEF_LIMITS,
4140 ) {
4141 if tx.id() == target_txid {
4142 let mut node = GASPNode {
4143 graph_id: graph_id.to_string(),
4144 raw_tx: tx.to_hex(),
4145 output_index,
4146 proof: None,
4147 tx_metadata: None,
4148 output_metadata: None,
4149 inputs: None,
4150 };
4151 if let Some(ref merkle_path) = tx.merkle_path {
4152 node.proof = Some(merkle_path.to_hex());
4153 }
4154 return Some(node);
4155 }
4156 }
4157 None
4158 }
4159
4160 pub async fn handle_new_merkle_proof(
4174 &self,
4175 txid: &str,
4176 proof_hex: &str,
4177 block_height: Option<u32>,
4178 ) -> Result<(), EngineError> {
4179 let outputs = self
4180 .storage
4181 .find_outputs_for_transaction(txid, true)
4182 .await
4183 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4184
4185 if outputs.is_empty() {
4186 return Err(EngineError::Other(
4187 "Could not find matching transaction outputs for proof ingest!".into(),
4188 ));
4189 }
4190
4191 let proof = if proof_hex.is_empty() {
4193 None
4194 } else {
4195 Some(
4196 beef_limits::merkle_path_from_hex(proof_hex, beef_limits::PUSH_PROOF_MAX_BYTES)
4197 .map_err(|e| EngineError::Other(format!("Invalid merkle proof hex: {e}")))?,
4198 )
4199 };
4200
4201 for output in &outputs {
4202 if let (Some(ref beef_data), Some(ref proof)) = (&output.beef, &proof) {
4205 if let Some(new_beef) = Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4206 {
4207 let _ = self
4208 .storage
4209 .update_transaction_beef(&output.txid, &new_beef)
4210 .await;
4211 }
4212 }
4213
4214 if let Some(height) = block_height {
4216 let _ = self
4217 .storage
4218 .update_output_block_height(
4219 &output.txid,
4220 output.output_index,
4221 &output.topic,
4222 height,
4223 )
4224 .await;
4225 }
4226
4227 for consuming in &output.consumed_by {
4229 if let Ok(consumed_outputs) = self
4230 .storage
4231 .find_outputs_for_transaction(&consuming.txid, true)
4232 .await
4233 {
4234 for consumed_output in &consumed_outputs {
4235 if let (Some(ref beef_data), Some(ref proof)) =
4241 (&consumed_output.beef, &proof)
4242 {
4243 if let Some(new_beef) =
4244 Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4245 {
4246 let _ = self
4247 .storage
4248 .update_transaction_beef(&consumed_output.txid, &new_beef)
4249 .await;
4250 }
4251 }
4252 }
4253 }
4254 }
4255 }
4256
4257 Ok(())
4258 }
4259
4260 pub async fn complete_missing_proofs(
4296 &self,
4297 limit: u64,
4298 min_age_secs: u64,
4299 ) -> Result<ProofCompletionSummary, EngineError> {
4300 let Some(fetcher) = self.ancestor_fetcher.as_deref() else {
4301 return Ok(ProofCompletionSummary::default());
4302 };
4303 self.complete_missing_proofs_with(fetcher, limit, min_age_secs)
4304 .await
4305 }
4306
4307 pub async fn complete_missing_proofs_with(
4310 &self,
4311 fetcher: &dyn crate::gasp::AncestorFetcher,
4312 limit: u64,
4313 min_age_secs: u64,
4314 ) -> Result<ProofCompletionSummary, EngineError> {
4315 let mut summary = ProofCompletionSummary::default();
4316
4317 let candidates = self
4318 .storage
4319 .find_transactions_for_proof_check(limit, min_age_secs)
4320 .await
4321 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4322 summary.scanned = candidates.len();
4323
4324 for cand in candidates {
4325 summary.early_gasp += usize::from(cand.early_gasp);
4326 let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4330 Ok(b) => b,
4331 Err(e) => {
4332 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4333 continue;
4334 }
4335 };
4336
4337 if let Some(bump_hex) = own_bump
4355 .as_ref()
4356 .map(bsv_rs::transaction::MerklePath::to_hex)
4357 {
4358 if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4359 let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4367 .ok()
4368 .map(|mp| u64::from(mp.block_height));
4369 if let Err(e) = self
4370 .storage
4371 .mark_transaction_proven_at(&cand.txid, height)
4372 .await
4373 {
4374 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4375 } else {
4376 summary.already_proven += 1;
4377 }
4378 continue;
4379 }
4380 warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4381 }
4382 summary.proofless += 1;
4384
4385 let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4390 summary.still_unconfirmed += 1;
4391 continue;
4392 };
4393
4394 let block_height =
4396 beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4397 .ok()
4398 .map(|mp| mp.block_height);
4399
4400 match self
4401 .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4402 .await
4403 {
4404 Ok(()) => summary.completed += 1,
4405 Err(e) => {
4406 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4407 summary.stitch_failed += 1;
4408 }
4409 }
4410 }
4411
4412 info!(
4413 scanned = summary.scanned,
4414 proofless = summary.proofless,
4415 completed = summary.completed,
4416 still_unconfirmed = summary.still_unconfirmed,
4417 fetch_failed = summary.fetch_failed,
4418 stitch_failed = summary.stitch_failed,
4419 already_proven = summary.already_proven,
4420 early_gasp = summary.early_gasp,
4421 "[PROOF COMPLETION] pass complete"
4422 );
4423
4424 Ok(summary)
4425 }
4426
4427 pub fn stitch_proof_into_stored_beef(
4468 stored: &[u8],
4469 txid: &str,
4470 proof: &bsv_rs::transaction::MerklePath,
4471 ) -> Option<Vec<u8>> {
4472 let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4473 beef.find_txid(txid)?;
4474 let i = beef.merge_bump(proof.clone());
4475 let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4479 b.path.first().is_some_and(|leaves| {
4480 leaves
4481 .iter()
4482 .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4483 })
4484 });
4485 if proves_subject {
4486 if let Some(tx) = beef.find_txid_mut(txid) {
4487 tx.set_bump_index(Some(i));
4488 }
4489 }
4490 Self::gc_unreferenced_bumps(&mut beef);
4491 Some(beef.to_binary())
4492 }
4493
4494 fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4500 let referenced: std::collections::HashSet<usize> = beef
4501 .txs
4502 .iter()
4503 .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4504 .collect();
4505 if referenced.len() == beef.bumps.len() {
4506 return;
4507 }
4508 let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4509 let mut kept = Vec::with_capacity(referenced.len());
4510 for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4511 if referenced.contains(&old) {
4512 remap[old] = Some(kept.len());
4513 kept.push(bump);
4514 }
4515 }
4516 beef.bumps = kept;
4517 for tx in &mut beef.txs {
4518 if let Some(old) = tx.bump_index() {
4519 tx.set_bump_index(remap[old]);
4520 }
4521 }
4522 }
4523
4524 pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4538 let mut report = SyncAdvertisementsReport::default();
4539
4540 let Some(advertiser) = &self.advertiser else {
4541 return Ok(report); };
4543
4544 let hosting_url = match &self.config.hosting_url {
4545 Some(url) if !url.is_empty() => url.clone(),
4546 _ => return Ok(report), };
4548
4549 let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4551 let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4552
4553 if self.config.suppress_default_sync_advertisements {
4554 configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4555 configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4556 }
4557
4558 let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4563 Ok(ads) => ads,
4564 Err(e) => {
4565 error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4566 report.lookup_error = Some(format!("ship: {e}"));
4567 return Ok(report);
4568 }
4569 };
4570 let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4571 Ok(ads) => ads,
4572 Err(e) => {
4573 error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4574 report.lookup_error = Some(format!("slap: {e}"));
4575 return Ok(report);
4576 }
4577 };
4578
4579 let ships_to_create: Vec<AdvertisementData> = configured_topics
4581 .iter()
4582 .filter(|topic| {
4583 !current_ship
4584 .iter()
4585 .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4586 })
4587 .map(|topic| AdvertisementData {
4588 protocol: Protocol::Ship,
4589 topic_or_service_name: topic.clone(),
4590 })
4591 .collect();
4592
4593 let slaps_to_create: Vec<AdvertisementData> = configured_services
4594 .iter()
4595 .filter(|service| {
4596 !current_slap
4597 .iter()
4598 .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4599 })
4600 .map(|service| AdvertisementData {
4601 protocol: Protocol::Slap,
4602 topic_or_service_name: service.clone(),
4603 })
4604 .collect();
4605
4606 let ships_to_revoke: Vec<Advertisement> = current_ship
4608 .into_iter()
4609 .filter(|a| !configured_topics.contains(&a.topic_or_service))
4610 .collect();
4611
4612 let slaps_to_revoke: Vec<Advertisement> = current_slap
4613 .into_iter()
4614 .filter(|a| !configured_services.contains(&a.topic_or_service))
4615 .collect();
4616
4617 let mut all_to_create = ships_to_create;
4619 all_to_create.extend(slaps_to_create);
4620 report.to_create = all_to_create.len();
4621 if !all_to_create.is_empty() {
4622 match advertiser.create_advertisements(&all_to_create).await {
4623 Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4624 Ok(steak) => {
4625 for (topic, instructions) in &steak {
4626 report
4627 .admitted
4628 .insert(topic.clone(), instructions.outputs_to_admit.len());
4629 }
4630 }
4631 Err(e) => {
4632 error!("Failed to submit new advertisements: {e}");
4633 report.submit_error = Some(e.to_string());
4634 }
4635 },
4636 Err(e) => {
4637 error!("Failed to create advertisements: {e}");
4638 report.create_error = Some(e.to_string());
4639 }
4640 }
4641 }
4642
4643 let mut all_to_revoke = ships_to_revoke;
4645 all_to_revoke.extend(slaps_to_revoke);
4646 report.to_revoke = all_to_revoke.len();
4647 if !all_to_revoke.is_empty() {
4648 match advertiser.revoke_advertisements(&all_to_revoke).await {
4649 Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4655 report.revoke_skipped = all_to_revoke.len();
4656 }
4657 Ok(tagged_beef) => {
4658 if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4659 error!("Failed to submit revocation: {e}");
4660 report.revoke_submit_error = Some(e.to_string());
4661 }
4662 }
4663 Err(e) => {
4664 error!("Failed to revoke advertisements: {e}");
4665 report.revoke_error = Some(e.to_string());
4666 }
4667 }
4668 }
4669
4670 Ok(report)
4671 }
4672
4673 #[allow(dead_code)] fn delete_utxo_deep<'a>(
4687 &'a self,
4688 output: &'a Output,
4689 bound: Option<&'a CallBound>,
4690 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4691 Box::pin(async move {
4692 if output.consumed_by.is_empty() {
4694 stored(
4695 bound,
4696 self.storage
4697 .delete_output(&output.txid, output.output_index, &output.topic),
4698 )
4699 .await
4700 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4701
4702 for ls in self.lookup_services.values() {
4705 let _ = hooked(
4706 bound,
4707 ls.output_no_longer_retained_in_history(
4708 &output.txid,
4709 output.output_index,
4710 &output.topic,
4711 ),
4712 )
4713 .await;
4714 }
4715 }
4716
4717 for consumed in &output.outputs_consumed {
4719 if let Ok(Some(mut stale_output)) = stored(
4720 bound,
4721 self.storage.find_output(
4722 &consumed.txid,
4723 consumed.output_index,
4724 Some(&output.topic),
4725 None,
4726 false,
4727 ),
4728 )
4729 .await
4730 {
4731 stale_output.consumed_by.retain(|c| {
4733 !(c.txid == output.txid && c.output_index == output.output_index)
4734 });
4735
4736 stored(
4737 bound,
4738 self.storage.update_consumed_by(
4739 &consumed.txid,
4740 consumed.output_index,
4741 &output.topic,
4742 &stale_output.consumed_by,
4743 ),
4744 )
4745 .await
4746 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4747
4748 let _ = self.delete_utxo_deep(&stale_output, bound).await;
4750 }
4751 }
4752
4753 Ok(())
4754 }) }
4756
4757 pub async fn renotify_admitted(
4788 &self,
4789 txid: &str,
4790 topic: &str,
4791 ) -> Result<RenotifyReport, EngineError> {
4792 let txid = txid.to_ascii_lowercase();
4793 let outputs = self
4794 .storage
4795 .find_outputs_for_transaction(&txid, true)
4796 .await
4797 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4798 let mut report = RenotifyReport::default();
4799 for output in outputs.iter().filter(|o| o.topic == topic) {
4800 report.outputs += 1;
4801 report.vouts.push(output.output_index);
4802 let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4805 beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4806 .ok()
4807 .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4808 .unwrap_or_else(|| beef.clone())
4809 });
4810 for (name, ls) in &self.lookup_services {
4811 let payload = match ls.admission_mode() {
4812 AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4813 txid: txid.clone(),
4814 output_index: output.output_index,
4815 topic: topic.to_string(),
4816 satoshis: output.satoshis,
4817 locking_script: output.output_script.clone(),
4818 off_chain_values: None,
4819 },
4820 AdmissionMode::WholeTx => {
4821 let Some(atomic_beef) = subject_named.as_ref() else {
4822 report.faults.push(format!(
4823 "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4824 output.output_index
4825 ));
4826 continue;
4827 };
4828 OutputAdmittedByTopic::WholeTx {
4829 atomic_beef: atomic_beef.clone(),
4830 output_index: output.output_index,
4831 topic: topic.to_string(),
4832 off_chain_values: None,
4833 }
4834 }
4835 };
4836 match ls.output_admitted_by_topic(&payload).await {
4837 Ok(()) => report.notified += 1,
4838 Err(e) => report.faults.push(format!("{name}: {e}")),
4839 }
4840 }
4841 }
4842 Ok(report)
4843 }
4844
4845 pub async fn forget_phantom_applied(
4856 &self,
4857 txid: &str,
4858 topic: &str,
4859 ) -> Result<bool, EngineError> {
4860 let txid = txid.to_ascii_lowercase();
4861 let outputs = self
4862 .storage
4863 .find_outputs_for_transaction(&txid, false)
4864 .await
4865 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4866 if outputs.iter().any(|o| o.topic == topic) {
4867 return Ok(false);
4868 }
4869 let rec = AppliedTransaction {
4870 txid: txid.clone(),
4871 topic: topic.to_string(),
4872 };
4873 let exists = self
4874 .storage
4875 .does_applied_transaction_exist(&rec)
4876 .await
4877 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4878 if !exists {
4879 return Ok(false);
4880 }
4881 self.storage
4882 .delete_applied_transaction(&rec)
4883 .await
4884 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4885 Ok(true)
4886 }
4887
4888 pub async fn evict_output(
4889 &self,
4890 txid: &str,
4891 output_index: u32,
4892 topic: Option<&str>,
4893 ) -> Result<(), EngineError> {
4894 if let Some(topic) = topic {
4895 self.storage
4897 .delete_output(txid, output_index, topic)
4898 .await
4899 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4900
4901 for ls in self.lookup_services.values() {
4903 let _ = ls.output_evicted(txid, output_index).await;
4904 }
4905 } else {
4906 let outputs = self
4908 .storage
4909 .find_outputs_for_transaction(txid, false)
4910 .await
4911 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4912
4913 for output in &outputs {
4914 if output.output_index == output_index {
4915 self.storage
4916 .delete_output(txid, output_index, &output.topic)
4917 .await
4918 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4919 }
4920 }
4921
4922 for ls in self.lookup_services.values() {
4924 let _ = ls.output_evicted(txid, output_index).await;
4925 }
4926 }
4927
4928 Ok(())
4929 }
4930
4931 pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
4956 self.start_gasp_sync_filtered(&crate::sync_load::SyncFilter::default())
4957 .await
4958 }
4959
4960 pub async fn start_gasp_sync_filtered(
4962 &self,
4963 filter: &crate::sync_load::SyncFilter,
4964 ) -> Result<GASPSyncResult, EngineError> {
4965 let meter = crate::meter::LoadMeter::default();
4966 self.start_gasp_sync_metered(filter, &meter).await
4967 }
4968
4969 pub async fn start_gasp_sync_metered(
4971 &self,
4972 filter: &crate::sync_load::SyncFilter,
4973 meter: &crate::meter::LoadMeter,
4974 ) -> Result<GASPSyncResult, EngineError> {
4975 let run = self.gasp_run();
4976 meter.scope(self.run_gasp_sync(filter, &run, None)).await
4977 }
4978
4979 fn gasp_run(&self) -> crate::backfill::Run {
4980 crate::backfill::Run::new(
4981 self.gasp_ancestor_fetcher
4982 .as_ref()
4983 .or(self.ancestor_fetcher.as_ref())
4984 .cloned(),
4985 )
4986 }
4987
4988 pub async fn start_gasp_backfill_metered(
4991 &self,
4992 request: &crate::backfill::SyncRequest,
4993 meter: &crate::meter::LoadMeter,
4994 ) -> Result<crate::backfill::BackfillResult, EngineError> {
4995 use crate::backfill::{
4996 BackfillResult, Continuation, Control, BACKFILL_MAX_SLICES, BACKFILL_REQUEST_MS,
4997 };
4998 request.validate().map_err(EngineError::InvalidQuery)?;
4999 let (topic, peer) = request.pair().map_err(EngineError::InvalidQuery)?;
5000 if request.until.is_none() || self.peer_sync_budget.is_none() || self.graph_budget.is_none()
5001 {
5002 return Err(EngineError::InvalidQuery(
5003 "backfill requires until: tip and peer/graph bounds".into(),
5004 ));
5005 }
5006 if self.gasp_remote_factory.is_none()
5007 || !matches!(
5008 self.config.sync_configuration.get(topic),
5009 Some(SyncTarget::Ship | SyncTarget::Peers(_))
5010 )
5011 {
5012 return Err(EngineError::InvalidQuery(
5013 "backfill topic is not enabled for sync".into(),
5014 ));
5015 }
5016 meter
5017 .scope(async {
5018 let initial = self.storage.get_last_interaction(peer, topic).await?;
5019 if let Some(token) = &request.continuation {
5020 let token = Continuation::decode(token).map_err(EngineError::InvalidQuery)?;
5021 if token.cursor > initial {
5022 return Err(EngineError::InvalidQuery(
5023 "continuation is ahead of the durable cursor".into(),
5024 ));
5025 }
5026 }
5027 let filter = request.filter();
5028 let run = self.gasp_run();
5029 let control = Control {
5030 meter: meter.clone(),
5031 budget_ms: request.budget_ms.unwrap_or(0).min(BACKFILL_REQUEST_MS),
5032 };
5033 let mut result = BackfillResult {
5034 sync: GASPSyncResult {
5035 filter: filter.clone(),
5036 ..GASPSyncResult::default()
5037 },
5038 slices: 0,
5039 reached_tip: false,
5040 stop_reason: "slice_allowance".into(),
5041 continuation: None,
5042 };
5043 let mut previous_cursor = initial;
5044 for _ in 0..BACKFILL_MAX_SLICES {
5045 if let Some(reason) = control.stop_reason() {
5046 result.stop_reason = reason.into();
5047 break;
5048 }
5049 let slice = self.run_gasp_sync(&filter, &run, Some(&control)).await?;
5050 let topic_result = slice.topics_synced.get(topic).ok_or_else(|| {
5051 EngineError::InvalidQuery("backfill selection matched no topic".into())
5052 })?;
5053 if !topic_result.peers.iter().any(|p| p == peer) {
5054 return Err(EngineError::InvalidQuery(
5055 "backfill selection matched no peer".into(),
5056 ));
5057 }
5058 let summary = topic_result.peer_results.get(peer).cloned();
5059 let waiting = !topic_result.peers_skipped.is_empty();
5060 result.sync.append_slice(slice);
5061 if let Some(summary) = summary {
5062 result.slices += summary.slices;
5063 if summary.reached_tip {
5064 result.reached_tip = true;
5065 result.stop_reason = "tip".into();
5066 break;
5067 }
5068 if let Some(reason) = control.stop_reason() {
5069 result.stop_reason = reason.into();
5070 break;
5071 }
5072 if summary.cursor <= previous_cursor {
5073 result.stop_reason = "no_progress".into();
5074 break;
5075 }
5076 previous_cursor = summary.cursor;
5077 } else {
5078 result.stop_reason =
5079 if waiting { "peer_wait" } else { "no_progress" }.into();
5080 break;
5081 }
5082 }
5083 let cursor = self.storage.get_last_interaction(peer, topic).await?;
5084 if !result.reached_tip {
5085 result.continuation = Some(Continuation::new(topic, peer, cursor).encode());
5086 }
5087 result.sync.cost = meter.snapshot();
5088 Ok(result)
5089 })
5090 .await
5091 }
5092
5093 async fn run_gasp_sync(
5094 &self,
5095 filter: &crate::sync_load::SyncFilter,
5096 run: &crate::backfill::Run,
5097 control: Option<&crate::backfill::Control>,
5098 ) -> Result<GASPSyncResult, EngineError> {
5099 use crate::gasp::{GASPSync, ENGINE_GASP_SYNC_PAGE_LIMIT};
5100 use crate::gasp_overlay::OverlayGASPStorage;
5101
5102 if self.config.sync_configuration.is_empty() {
5103 info!("[GASP SYNC] No sync configuration — nothing to sync");
5104 return Ok(GASPSyncResult {
5105 cost: crate::meter::SyncCost::default(),
5106 topics_synced: HashMap::new(),
5107 filter: filter.clone(),
5108 topics_filtered: Vec::new(),
5109 });
5110 }
5111
5112 let sync_fetcher = &run.fetcher;
5113 let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
5114 let mut ship_lookup_hosts = None;
5117 let mut topic_order = sync_order(&self.config.sync_configuration);
5118 topic_order.sort_by_key(|(topic, target)| {
5121 !(matches!(target, SyncTarget::Ship)
5122 && self
5123 .ship_bootstrap_peers
5124 .get(topic.as_str())
5125 .is_some_and(|peers| !peers.is_empty()))
5126 });
5127
5128 let mut topics_filtered = Vec::new();
5129 for (topic, target) in topic_order {
5130 if filter
5131 .topics
5132 .as_ref()
5133 .is_some_and(|names| !names.contains(topic))
5134 {
5135 topics_filtered.push(topic.clone());
5136 continue;
5137 }
5138 let cost_before = crate::meter::snapshot();
5139 let configured_peers = matches!(target, SyncTarget::Peers(_));
5142 let (peers, sync_type) = match target {
5143 SyncTarget::Disabled => {
5144 info!("[GASP SYNC] Topic {topic} is disabled — skipping");
5145 continue;
5146 }
5147 SyncTarget::Ship => {
5148 info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
5149 let peers = if let Some(peers) = &filter.peers {
5150 self.ship_peers_excluding_self(peers.clone())
5151 } else {
5152 self.discover_ship_peers(topic, &mut ship_lookup_hosts)
5153 .await
5154 };
5155 info!(
5156 "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
5157 peers.len()
5158 );
5159 (peers, "ship".to_string())
5160 }
5161 SyncTarget::Peers(peer_urls) => {
5162 info!(
5163 "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
5164 peer_urls.len()
5165 );
5166 let peers: Vec<String> = peer_urls
5167 .iter()
5168 .filter(|url| {
5169 if let Some(ref our_url) = self.config.hosting_url {
5170 url.trim_end_matches('/') != our_url.trim_end_matches('/')
5171 } else {
5172 true
5173 }
5174 })
5175 .cloned()
5176 .collect();
5177
5178 info!(
5179 "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
5180 peers.len()
5181 );
5182 (peers, "peers".to_string())
5183 }
5184 };
5185
5186 let (peers, peers_filtered): (Vec<_>, Vec<_>) = peers.into_iter().partition(|peer| {
5187 filter
5188 .peers
5189 .as_ref()
5190 .is_none_or(|names| names.contains(peer))
5191 });
5192 let mut peers_skipped = Vec::new();
5193 let advertisements = self.peer_advertisement_versions(topic).await;
5194 let mut errors = Vec::new();
5195 let mut pruned_inputs: u64 = 0;
5196 let mut discarded_graphs: u64 = 0;
5197 let mut expired_refused: u64 = 0;
5198 let mut roots_proven = 0;
5199 let mut roots_unproven = 0;
5200 let mut cursor_held_by = Vec::new();
5201 let expired = run.expired(topic);
5202 let mut peer_results = HashMap::new();
5203 let mut anchor_walk_held_graphs: u64 = 0;
5204 let mut finalized_graphs: u64 = 0;
5205 let mut deadline_dropped_graphs: u64 = 0;
5206 let mut cursor_moves: Vec<CursorMove> = Vec::new();
5207 let mut deferred_graphs: u64 = 0;
5208 let mut resumed_graphs: u64 = 0;
5209 let mut converged_graphs: u64 = 0;
5210 let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
5211 let mut stalled_graphs: u64 = 0;
5212 let mut held_back_graphs: u64 = 0;
5213 let mut dead_graphs = 0;
5214 let mut expired_swept = 0;
5215 let mut memo_folded = 0;
5216 let mut unanchorable_graphs: u64 = 0;
5217 let mut unanchorable_cleared: u64 = 0;
5218 let mut absent_struck: u64 = 0;
5219 let mut route_faults: Vec<crate::route_fault::RouteFaultSeen> = Vec::new();
5220 let mut strikes_refused: u64 = 0;
5221 let mut backed_off_graphs: u64 = 0;
5222 let mut waiting_graphs: u64 = 0;
5223 let mut idle_counted: u64 = 0;
5224 let mut root_bounds = Vec::new();
5225 let mut faulted_roots: Vec<crate::gasp::FaultedRoot> = Vec::new();
5226
5227 if let Some(ref factory) = self.gasp_remote_factory {
5229 for peer_url in &peers {
5230 let health_key = crate::gasp::peer_origin(peer_url);
5243 let health = self
5244 .storage
5245 .get_peer_sync_health(&health_key, topic)
5246 .await
5247 .unwrap_or_default();
5248 if crate::gasp::peer_sync_quarantined(&health) {
5249 peers_skipped.push(crate::sync_load::SkippedPeer {
5250 peer: peer_url.clone(),
5251 reason: "quarantine".into(),
5252 retry_after_secs: crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5253 .saturating_sub(health.secs_since_last_attempt.unwrap_or(0)),
5254 });
5255 warn!(
5256 "[GASP SYNC] Peer {peer_url} for {topic} QUARANTINED ({} consecutive failed syncs, last attempt {:?}s ago) — skipped until the {}s re-probe window (bsv-low#302)",
5257 health.consecutive_failures,
5258 health.secs_since_last_attempt,
5259 crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5260 );
5261 continue;
5262 }
5263
5264 let cadence = self
5265 .storage
5266 .get_peer_cadence(&health_key, topic)
5267 .await
5268 .unwrap_or_default();
5269 let advertisement =
5270 advertisements.get(&health_key).cloned().unwrap_or_default();
5271 let reset_cadence = filter.explicit() || cadence.advertisement != advertisement;
5272 if !reset_cadence && cadence.retry_after_secs > 0 {
5273 peers_skipped.push(crate::sync_load::SkippedPeer {
5274 peer: peer_url.clone(),
5275 reason: "no_yield".into(),
5276 retry_after_secs: cadence.retry_after_secs,
5277 });
5278 continue;
5279 }
5280
5281 let peer_cost_before = crate::meter::snapshot();
5282 info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
5283
5284 let last_interaction = self
5286 .storage
5287 .get_last_interaction(peer_url, topic)
5288 .await
5289 .unwrap_or(0);
5290
5291 let sink = crate::gasp_overlay::new_finalized_graph_sink();
5293
5294 let hydration_on = sync_fetcher.is_some();
5303
5304 let mut gasp_storage =
5306 OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5307 .with_peer(peer_url.as_str())
5308 .with_configured_peer(configured_peers)
5309 .with_strict_beef(hydration_on)
5310 .with_script_verification(self.verify_scripts)
5311 .with_walk_budget(self.walk_budget);
5312 if let Some(manager) = self.managers.get(topic) {
5313 gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5314 }
5315 if let Some(tracker) = self.chain_tracker.as_deref() {
5320 gasp_storage = gasp_storage.with_chain_tracker(tracker);
5321 }
5322 let gasp_remote = Box::new(crate::load_remote::MeteredRemote(
5323 factory.create_remote(peer_url, topic),
5324 ));
5325
5326 let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5327 let peer_finalized = std::cell::Cell::new(0u64);
5329 let mut sync = GASPSync::new(
5330 Box::new(gasp_storage),
5331 gasp_remote,
5332 last_interaction,
5333 &log_prefix,
5334 true, )
5336 .with_ancestor_fetcher(sync_fetcher.clone())
5337 .with_expired_outpoints(std::rc::Rc::clone(&expired));
5338 if let Some(control) = control {
5339 let control = control.clone();
5340 sync = sync.with_page_slice(std::rc::Rc::new(move || {
5341 control.stop_reason().is_some()
5342 }));
5343 }
5344 if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5347 let (sleep, budget_ms) =
5348 (sleep.clone(), (*budget_ms).min(crate::meter::ROOT_MAX_MS));
5349 let half_sleep = sleep.clone();
5350 let share_sleep = sleep.clone();
5351 sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5352 max_calls: *max_calls,
5353 max_bytes_fetched: self.graph_budget_limbs.0,
5354 max_nodes: self.graph_budget_limbs.1,
5355 deadline: Box::new(move || sleep(budget_ms)),
5356 half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5357 });
5358 if let Some((_, peer_ms)) = &self.peer_sync_budget {
5361 let share_ms = crate::gasp::resume_share_ms(*peer_ms, budget_ms);
5362 sync = sync.with_resume_share(Box::new(move || share_sleep(share_ms)));
5363 }
5364 }
5365 if self.peer_sync_budget.is_some() {
5370 sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5371 engine: self,
5372 sink: sink.clone(),
5373 peer_url,
5374 topic,
5375 submitted: &peer_finalized,
5376 }));
5377 }
5378
5379 let sync_outcome = match &self.peer_sync_budget {
5389 Some((sleep, budget_ms)) => {
5390 let budget_ms =
5391 control.map_or(*budget_ms, |c| (*budget_ms).min(c.remaining_ms()));
5392 let mut deadline = sleep(budget_ms);
5393 let deadline = std::future::poll_fn(|cx| {
5394 if control.is_some_and(|c| c.stop_reason().is_some())
5395 || std::future::Future::poll(deadline.as_mut(), cx).is_ready()
5396 {
5397 std::task::Poll::Ready(())
5398 } else {
5399 std::task::Poll::Pending
5400 }
5401 });
5402 crate::gasp::race_or_deadline_guarded(
5403 sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5404 deadline,
5405 &self.finalize_gate,
5406 )
5407 .await
5408 }
5409 None => Some(sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)).await),
5410 };
5411
5412 let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5413 let route_faulted = matches!(
5416 sync_outcome,
5417 Some(Err(crate::gasp::GASPError::RouteFault { .. }))
5418 );
5419 pruned_inputs += sync.pruned_inputs();
5425 expired_refused += sync.expired_refused();
5426 roots_proven += sync.roots_proven();
5427 roots_unproven += sync.roots_unproven();
5428 discarded_graphs += sync.discarded_graphs();
5433 anchor_walk_held_graphs += sync.anchor_walk_held();
5434 let ran = !matches!(sync_outcome, Some(Err(_)));
5438 let mut advanced_cursor = false;
5439 match sync_outcome {
5440 None => {
5441 let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5442 let msg = format!(
5443 "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5444 );
5445 warn!("[GASP SYNC] {msg}");
5446 errors.push(msg);
5447
5448 let in_flight = u64::from(sync.graph_in_flight());
5459 deadline_dropped_graphs += in_flight;
5460 sync.defer_in_flight().await;
5463 let completed = sync.completed_cursor();
5464 if completed > last_interaction {
5465 match self
5466 .storage
5467 .update_last_interaction(peer_url, topic, completed)
5468 .await
5469 {
5470 Ok(()) => cursor_moves.push(CursorMove {
5471 peer: peer_url.clone(),
5472 from: last_interaction,
5473 to: completed,
5474 }),
5475 Err(e) => warn!(
5476 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5477 ),
5478 }
5479 }
5480 advanced_cursor = completed > last_interaction;
5500 outcome_success = peer_finalized.get() > 0
5501 || (completed > last_interaction && sync.cursor_yielded())
5502 || sync.deferral_stats().progressed > 0
5503 || sync.did_work();
5504 warn!(
5505 "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5506 peer_finalized.get(),
5507 completed.max(last_interaction)
5508 );
5509 }
5510 Some(Ok(())) => {
5511 let landed = self
5515 .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5516 .await;
5517 let stats = sync.deferral_stats();
5528 if stats.stalled > 0
5529 && stats.progressed == 0
5530 && peer_finalized.get() == 0
5531 && !sync.did_work()
5532 && !(landed
5533 && sync.last_interaction > last_interaction
5534 && sync.cursor_yielded())
5535 {
5536 outcome_success = false;
5537 let msg = format!(
5538 "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5539 stats.stalled
5540 );
5541 warn!("[GASP SYNC] {msg}");
5542 errors.push(msg);
5543 }
5544
5545 advanced_cursor = landed && sync.last_interaction > last_interaction;
5564 if !landed {
5565 warn!(
5566 "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5567 );
5568 } else if sync.last_interaction > last_interaction {
5569 match self
5570 .storage
5571 .update_last_interaction(peer_url, topic, sync.last_interaction)
5572 .await
5573 {
5574 Ok(()) => cursor_moves.push(CursorMove {
5575 peer: peer_url.clone(),
5576 from: last_interaction,
5577 to: sync.last_interaction,
5578 }),
5579 Err(e) => warn!(
5580 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5581 ),
5582 }
5583 }
5584 info!(
5585 "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5586 sync.last_interaction
5587 );
5588 }
5589 Some(Err(e)) => {
5590 let msg = format!("{peer_url}: {e}");
5591 warn!("[GASP SYNC] Sync failed: {msg}");
5592 errors.push(msg);
5593 if let crate::gasp::GASPError::RouteFault { class, .. } = e {
5594 route_faults.push(crate::route_fault::RouteFaultSeen {
5595 peer: peer_url.clone(),
5596 class,
5597 });
5598 }
5599 }
5600 }
5601 let yielded = peer_finalized.get() > 0
5618 || (advanced_cursor && sync.cursor_yielded())
5619 || sync.expired_refused() > 0;
5620 if yielded || sync.did_work() {
5621 outcome_success = true;
5622 }
5623 let route_excused = route_faulted && !outcome_success;
5631 if route_excused {
5632 info!(
5633 "[GASP SYNC] {peer_url} for {topic}: {} before the peer was reached; nothing counted against the peer (#57)",
5634 route_faults
5635 .last()
5636 .map_or(String::new(), crate::route_fault::RouteFaultSeen::label)
5637 );
5638 }
5639 if ran
5650 && self.graph_budget.is_some()
5651 && (yielded || sync.graphs_attempted() > 0)
5652 {
5653 match self
5654 .storage
5655 .record_peer_sync_yield(&health_key, topic, yielded)
5656 .await
5657 {
5658 Ok(streak)
5659 if !yielded
5660 && crate::gasp::yieldless_sync_failed(&streak)
5661 && outcome_success =>
5662 {
5663 outcome_success = false;
5664 let msg = format!(
5665 "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5666 streak.yieldless_syncs,
5667 streak.secs_since_first.unwrap_or(0),
5668 crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5669 crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5670 );
5671 warn!("[GASP SYNC] {msg}");
5672 errors.push(msg);
5673 }
5674 Ok(_) => {}
5675 Err(e) => warn!(
5676 "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5677 ),
5678 }
5679 }
5680 let quiet = outcome_success
5683 && !yielded
5684 && !sync.did_work()
5685 && sync.faulted_roots().is_empty()
5686 && sync.deferral_stats().stalled == 0;
5687 let quiet_syncs = if quiet && !filter.explicit() {
5688 if reset_cadence {
5689 1
5690 } else {
5691 cadence.quiet_syncs.saturating_add(1)
5692 }
5693 } else {
5694 0
5695 };
5696 let next = crate::sync_load::PeerCadence {
5697 quiet_syncs,
5698 retry_after_secs: crate::sync_load::quiet_retry_secs(quiet_syncs),
5699 advertisement,
5700 };
5701 if !route_excused {
5702 if let Err(e) = self
5703 .storage
5704 .set_peer_cadence(&health_key, topic, &next)
5705 .await
5706 {
5707 warn!(
5708 "[GASP SYNC] failed to record cadence for {peer_url}/{topic}: {e}"
5709 );
5710 }
5711 }
5712 if !advanced_cursor {
5713 if let Some(outpoint) = sync.cursor_held_by() {
5714 cursor_held_by.push(outpoint);
5715 }
5716 }
5717 finalized_graphs += peer_finalized.get();
5718 let deferral = sync.deferral_stats();
5719 root_bounds.extend(deferral.root_bounds.iter().cloned().map(|mut bound| {
5720 bound.peer.clone_from(peer_url);
5721 bound
5722 }));
5723 deferred_graphs += deferral.deferred;
5724 resumed_graphs += deferral.resumed;
5725 converged_graphs += deferral.converged;
5726 stalled_graphs += deferral.stalled;
5727 held_back_graphs += deferral.held_back;
5728 backed_off_graphs += deferral.backed_off;
5729 waiting_graphs += deferral.waiting;
5730 idle_counted += deferral.idle_counted;
5731 dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5732 peer: peer_url.clone(),
5733 outpoint: d.outpoint,
5734 reason: d.reason.as_str().to_string(),
5735 }));
5736 dead_graphs += sync.dead_recorded();
5738 expired_swept += sync.expired_swept();
5739 memo_folded += sync.memo_folded();
5740 unanchorable_graphs += sync.unanchorable_recorded();
5741 unanchorable_cleared += sync.unanchorable_cleared();
5742 absent_struck += sync.absent_struck();
5743 strikes_refused += sync.strikes_refused();
5745 faulted_roots.extend(sync.faulted_roots().into_iter().map(|mut f| {
5746 f.peer.clone_from(peer_url);
5747 f
5748 }));
5749
5750 if route_excused {
5770 } else if outcome_success
5772 && !yielded
5773 && self.graph_budget.is_some()
5774 && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5775 {
5776 info!(
5777 "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5778 health.consecutive_failures
5779 );
5780 } else if let Err(e) = self
5781 .storage
5782 .record_peer_sync_outcome(&health_key, topic, outcome_success)
5783 .await
5784 {
5785 warn!(
5786 "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5787 );
5788 }
5789 let cursor = self.storage.get_last_interaction(peer_url, topic).await?;
5790 let cost = crate::meter::snapshot().since(peer_cost_before);
5791 peer_results.insert(
5792 peer_url.clone(),
5793 crate::backfill::PeerSyncResult {
5794 slices: u64::from(sync.page_advanced() || cost.outputs_walked > 0),
5795 pages: sync.pages(),
5796 reached_tip: sync.reached_tip() && cursor >= sync.completed_cursor(),
5797 cursor,
5798 cost,
5799 },
5800 );
5801 }
5802 }
5803
5804 topics_synced.insert(
5805 topic.clone(),
5806 TopicSyncResult {
5807 cost: crate::meter::snapshot().since(cost_before),
5808 root_bounds,
5809 peers_skipped,
5810 peers_filtered,
5811 roots_proven,
5812 roots_unproven,
5813 cursor_held_by,
5814 expired_refused,
5815 peers,
5816 sync_type,
5817 errors,
5818 pruned_inputs,
5819 discarded_graphs,
5820 anchor_walk_held_graphs,
5821 finalized_graphs,
5822 deadline_dropped_graphs,
5823 cursor_moves,
5824 deferred_graphs,
5825 resumed_graphs,
5826 converged_graphs,
5827 dropped_graphs,
5828 stalled_graphs,
5829 held_back_graphs,
5830 backed_off_graphs,
5831 waiting_graphs,
5832 idle_counted,
5833 peer_results,
5834 dead_graphs,
5835 expired_swept,
5836 memo_folded,
5837 unanchorable_graphs,
5838 unanchorable_cleared,
5839 absent_struck,
5840 route_faults,
5841 strikes_refused,
5842 faulted_roots,
5843 },
5844 );
5845 }
5846
5847 info!(
5848 "[GASP SYNC] Peer discovery complete for {} topic(s)",
5849 topics_synced.len()
5850 );
5851
5852 let expired: u64 = topics_synced.values().map(|r| r.expired_refused).sum();
5853 if expired > 0 {
5854 info!("[GASP SYNC] gaspExpiredRefused={expired}");
5855 }
5856 let unanchorable: u64 = topics_synced.values().map(|r| r.unanchorable_graphs).sum();
5857 if unanchorable > 0 {
5858 info!("[GASP SYNC] gaspUnanchorable={unanchorable}");
5859 }
5860 let absent: u64 = topics_synced.values().map(|r| r.absent_struck).sum();
5861 if absent > 0 {
5862 info!("[GASP SYNC] gaspAbsentStruck={absent}");
5863 }
5864 let routes: usize = topics_synced.values().map(|r| r.route_faults.len()).sum();
5865 if routes > 0 {
5866 info!("[GASP SYNC] gaspRouteFaults={routes}");
5867 }
5868 let refused: u64 = topics_synced.values().map(|r| r.strikes_refused).sum();
5869 if refused > 0 {
5870 info!("[GASP SYNC] gaspStrikesRefused={refused}");
5871 }
5872 Ok(GASPSyncResult {
5873 cost: crate::meter::snapshot(),
5874 topics_synced,
5875 filter: filter.clone(),
5876 topics_filtered,
5877 })
5878 }
5879
5880 async fn submit_finalized_graphs(
5904 &self,
5905 sink: &crate::gasp_overlay::FinalizedGraphSink,
5906 peer_url: &str,
5907 topic: &str,
5908 submitted: &std::cell::Cell<u64>,
5909 ) -> bool {
5910 let finalized: Vec<_> = sink
5911 .lock()
5912 .unwrap_or_else(std::sync::PoisonError::into_inner)
5913 .drain(..)
5914 .collect();
5915
5916 let mut all_landed = true;
5917 for graph in &finalized {
5918 for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5919 self.finalize_gate.stop_if_due().await;
5920 if position == 0 {
5921 submitted.set(submitted.get() + 1);
5922 }
5923 let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5924 let outcome = {
5934 let _writing = self.finalize_gate.write_section();
5935 let bound = self
5936 .finalize_submit_budget
5937 .as_ref()
5938 .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5939 self.submit_bounded(
5940 &tagged,
5941 SubmitMode::HistoricalTxNoSpv,
5942 bound.as_ref(),
5943 true,
5944 WalkBreachPolicy::NotNow,
5945 )
5946 .await
5947 };
5948 let not_landed = match outcome {
5949 Ok((_, report)) => {
5950 let landed = report.is_durable()
5951 && (report.applied_topics.contains(&graph.topic)
5952 || report.deduped_topics.contains(&graph.topic));
5953 (!landed).then(|| {
5954 if report.is_durable() {
5955 "the topic manager failed on it".to_string()
5956 } else {
5957 format!("not durable: {}", report.summary())
5958 }
5959 })
5960 }
5961 Err(e) => Some(e.to_string()),
5962 };
5963 if let Some(why) = not_landed {
5964 warn!(
5965 "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
5966 graph.topic
5967 );
5968 all_landed = false;
5969 break;
5970 }
5971 }
5972 }
5973
5974 if !finalized.is_empty() {
5975 info!(
5976 "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
5977 finalized.len()
5978 );
5979 }
5980 all_landed
5981 }
5982
5983 pub async fn rerequest_held_proofs(
5989 &self,
5990 candidates: &[crate::proof_window::HeldOutput],
5991 progress: &std::cell::Cell<crate::proof_window::PeerProofSummary>,
5992 ) {
5993 use crate::proof_window::{PEER_PROOF_REQUEST_MS, PEER_PROOF_ROW_LIMIT};
5994 let Some(factory) = &self.gasp_remote_factory else {
5995 return;
5996 };
5997 let Some(tracker) = self.chain_tracker.as_deref() else {
5998 return;
5999 };
6000 let mut lookup_hosts = None;
6001 let mut topic_peers = HashMap::new();
6002 let mut landed = HashSet::new();
6003 for candidate in candidates.iter().take(PEER_PROOF_ROW_LIMIT) {
6004 if !self.managers.contains_key(&candidate.topic) {
6005 continue;
6006 }
6007 if let std::collections::hash_map::Entry::Vacant(entry) =
6008 topic_peers.entry(candidate.topic.clone())
6009 {
6010 let peers = match self.config.sync_configuration.get(&candidate.topic) {
6011 Some(SyncTarget::Ship) => {
6012 self.discover_ship_peers(&candidate.topic, &mut lookup_hosts)
6013 .await
6014 }
6015 Some(SyncTarget::Peers(peers)) => self.ship_peers_excluding_self(peers.clone()),
6016 _ => Vec::new(),
6017 };
6018 entry.insert(peers);
6019 }
6020 for peer in &topic_peers[&candidate.topic] {
6021 let remote = factory.create_remote(peer, &candidate.topic);
6022 let graph_id = format!("{}.{}", candidate.txid, candidate.output_index);
6023 let mut summary = progress.get();
6024 summary.requests += 1;
6025 progress.set(summary);
6026 let ask =
6027 remote.request_node(&graph_id, &candidate.txid, candidate.output_index, false);
6028 let response = if let Some((sleep, _)) = &self.peer_sync_budget {
6029 crate::gasp::race_or_deadline(ask, sleep(PEER_PROOF_REQUEST_MS)).await
6030 } else {
6031 Some(ask.await)
6032 };
6033 let Some(Ok(node)) = response else {
6034 let mut summary = progress.get();
6035 summary.faults += 1;
6036 progress.set(summary);
6037 continue;
6038 };
6039 if landed.contains(&candidate.txid) {
6040 continue;
6041 }
6042 let Some(proof_hex) = node.proof else {
6043 continue;
6044 };
6045 let Ok(mut proof) = beef_limits::merkle_path_from_hex(
6046 &proof_hex,
6047 beef_limits::COURIER_PROOF_MAX_BYTES,
6048 ) else {
6049 let mut summary = progress.get();
6050 summary.refused += 1;
6051 progress.set(summary);
6052 continue;
6053 };
6054 let valid = match proof.compute_root(Some(&candidate.txid)) {
6055 Ok(root) => tracker
6056 .is_valid_root_for_height(&root, proof.block_height)
6057 .await
6058 .unwrap_or(false),
6059 Err(_) => false,
6060 };
6061 if !valid {
6062 let mut summary = progress.get();
6063 summary.refused += 1;
6064 progress.set(summary);
6065 continue;
6066 }
6067 for leaf in &mut proof.path[0] {
6070 if leaf.hash.as_deref() == Some(&candidate.txid) {
6071 leaf.txid = true;
6072 }
6073 }
6074 match self.land_peer_proof(candidate, &proof).await {
6075 Ok(()) => {
6076 landed.insert(candidate.txid.clone());
6077 let mut summary = progress.get();
6078 summary.landed += 1;
6079 progress.set(summary);
6080 }
6081 Err(error) => {
6082 warn!(%error, "[proof window] verified proof could not be stored");
6083 let mut summary = progress.get();
6084 summary.faults += 1;
6085 progress.set(summary);
6086 }
6087 }
6088 }
6089 }
6090 }
6091
6092 async fn land_peer_proof(
6093 &self,
6094 candidate: &crate::proof_window::HeldOutput,
6095 proof: &bsv_rs::transaction::MerklePath,
6096 ) -> Result<(), EngineError> {
6097 let output = self
6098 .storage
6099 .find_output(
6100 &candidate.txid,
6101 candidate.output_index,
6102 Some(&candidate.topic),
6103 None,
6104 true,
6105 )
6106 .await
6107 .map_err(|e| EngineError::StorageError(e.to_string()))?
6108 .ok_or(EngineError::NodeNotFound)?;
6109 let beef = output
6110 .beef
6111 .ok_or_else(|| EngineError::BeefParseError("missing stored BEEF".into()))?;
6112 let stitched = Self::stitch_proof_into_stored_beef(&beef, &candidate.txid, proof)
6113 .ok_or_else(|| {
6114 EngineError::BeefParseError("proof does not stitch into stored subject".into())
6115 })?;
6116 self.storage
6117 .update_transaction_beef(&candidate.txid, &stitched)
6118 .await
6119 .map_err(|e| EngineError::StorageError(e.to_string()))?;
6120 self.storage
6121 .mark_transaction_proven_at(&candidate.txid, Some(u64::from(proof.block_height)))
6122 .await
6123 .map_err(|e| EngineError::StorageError(e.to_string()))
6124 }
6125
6126 async fn peer_advertisement_versions(&self, topic: &str) -> HashMap<String, String> {
6129 let mut versions: HashMap<String, Vec<String>> = HashMap::new();
6130 for (ad_topic, protocol, scope) in [
6131 ("tm_slap", &b"SLAP"[..], None),
6132 ("tm_ship", &b"SHIP"[..], Some(topic)),
6133 ] {
6134 let rows = self
6135 .storage
6136 .find_utxos_for_topic(ad_topic, None, None, false)
6137 .await
6138 .unwrap_or_default();
6139 for row in rows {
6140 if let Some(domain) =
6141 parse_advertisement_domain_from_script(&row.output_script, protocol, scope)
6142 {
6143 versions
6144 .entry(crate::gasp::peer_origin(&domain))
6145 .or_default()
6146 .push(format!("{}:{}", row.txid, row.output_index));
6147 }
6148 }
6149 }
6150 versions
6151 .into_iter()
6152 .map(|(peer, mut keys)| {
6153 keys.sort();
6154 (peer, keys.join(","))
6155 })
6156 .collect()
6157 }
6158
6159 async fn discover_ship_peers(
6160 &self,
6161 topic: &str,
6162 lookup_hosts: &mut Option<Vec<String>>,
6163 ) -> Vec<String> {
6164 let mut domains = self.local_ship_peers(topic).await;
6165 if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
6166 domains.extend(bootstrap.iter().cloned());
6167 }
6168 if let Some(client) = &self.ship_lookup_client {
6169 if lookup_hosts.is_none() {
6170 *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
6171 }
6172 let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
6173 for host in lookup_hosts.as_ref().into_iter().flatten() {
6174 crate::meter::peer_request();
6175 match client.lookup(host, &question).await {
6176 Ok(outputs) => {
6177 domains.extend(outputs.iter().filter_map(|output| {
6178 parse_lookup_advertisement(output, b"SHIP", topic)
6179 }));
6180 }
6181 Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
6182 }
6183 }
6184 }
6185 self.ship_peers_excluding_self(domains)
6186 }
6187
6188 async fn discover_ship_lookup_hosts(
6189 &self,
6190 client: &dyn crate::ship_lookup::ShipLookupClient,
6191 ) -> Vec<String> {
6192 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
6193 let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
6194 let tracker_origins: HashSet<_> = trackers
6195 .iter()
6196 .map(|host| crate::gasp::peer_origin(host))
6197 .collect();
6198 let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
6199 let mut advertised = Vec::new();
6200 for tracker in &trackers {
6201 crate::meter::peer_request();
6202 match client.lookup(tracker, &question).await {
6203 Ok(outputs) => {
6204 advertised.extend(outputs.iter().filter_map(|output| {
6205 parse_lookup_advertisement(output, b"SLAP", "ls_ship")
6206 }));
6207 }
6208 Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
6209 }
6210 }
6211 let providers = self
6212 .ship_peers_excluding_self(advertised)
6213 .into_iter()
6214 .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
6215 .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
6216 trackers.into_iter().chain(providers).collect()
6217 }
6218
6219 fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
6220 crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
6221 self.config.hosting_url.as_ref().is_none_or(|our_url| {
6222 crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
6223 })
6224 }))
6225 }
6226
6227 async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
6229 let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
6230 warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
6231 return Vec::new();
6232 };
6233
6234 let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
6235
6236 let refs = match ship_ls.lookup(&question).await {
6237 Ok(LookupResult::OutputList(refs)) => refs,
6238 Ok(LookupResult::Answer(_)) => {
6239 error!(
6240 "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
6241 LookupAnswer; expected OutputList. Skipping."
6242 );
6243 return Vec::new();
6244 }
6245 Err(e) => {
6246 error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
6247 return Vec::new();
6248 }
6249 };
6250
6251 let mut domains = HashSet::new();
6252 for reference in &refs {
6253 if let Ok(Some(output)) = self
6254 .storage
6255 .find_output(&reference.txid, reference.output_index, None, None, false)
6256 .await
6257 {
6258 if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
6259 if let Some(ref our_url) = self.config.hosting_url {
6263 if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
6264 || crate::gasp::peer_origin(&domain)
6265 == crate::gasp::peer_origin(our_url)
6266 {
6267 continue;
6268 }
6269 }
6270 domains.insert(domain);
6271 }
6272 }
6273 }
6274
6275 crate::gasp::ship_peers_by_origin(domains)
6279 }
6280
6281 pub fn storage(&self) -> &dyn Storage {
6287 self.storage.as_ref()
6288 }
6289
6290 pub fn config(&self) -> &EngineConfig {
6292 &self.config
6293 }
6294}
6295
6296pub(crate) async fn verify_spv_like_the_reference(
6313 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6314 verify_scripts: bool,
6315 beef_bytes: &[u8],
6316 subject_txid: &str,
6317 budget: DoorBudget,
6318) -> Result<Option<WalkCouldNotRun>, EngineError> {
6319 verify_spv_trusting(
6320 chain_tracker,
6321 verify_scripts,
6322 beef_bytes,
6323 subject_txid,
6324 &HashSet::new(),
6325 budget,
6326 )
6327 .await
6328}
6329
6330async fn verify_spv_trusting(
6335 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6336 verify_scripts: bool,
6337 beef_bytes: &[u8],
6338 subject_txid: &str,
6339 trusted: &HashSet<String>,
6340 budget: DoorBudget,
6341) -> Result<Option<WalkCouldNotRun>, EngineError> {
6342 if verify_scripts {
6343 Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
6344 } else {
6345 Engine::verify_spv_structurally(chain_tracker, beef_bytes)
6346 .await
6347 .map(|()| None)
6348 }
6349}
6350
6351fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
6355 let mut proven: HashMap<&str, usize> = HashMap::new();
6356 for (at, bump) in beef.bumps.iter().enumerate() {
6357 for leaf in bump.path.first().into_iter().flatten() {
6358 if let Some(hash) = leaf.hash.as_deref() {
6359 proven.entry(hash).or_insert(at);
6360 }
6361 }
6362 }
6363 proven
6364}
6365
6366fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
6371 let bodies: HashMap<String, &Transaction> = beef
6372 .txs
6373 .iter()
6374 .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
6375 .collect();
6376 let proven = proven_index(beef);
6377 let mut cover = HashSet::new();
6378 let mut queue = vec![from.to_string()];
6379 while let Some(txid) = queue.pop() {
6380 if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
6381 continue;
6382 }
6383 if let Some(tx) = bodies.get(&txid) {
6384 queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
6385 }
6386 }
6387 cover
6388}
6389
6390struct SubmitAsFinalized<'e> {
6393 engine: &'e Engine,
6394 sink: crate::gasp_overlay::FinalizedGraphSink,
6395 peer_url: &'e str,
6396 topic: &'e str,
6397 submitted: &'e std::cell::Cell<u64>,
6398}
6399
6400#[async_trait::async_trait(?Send)]
6401impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
6402 async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
6403 let landed = self
6407 .engine
6408 .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
6409 .await;
6410 if landed {
6411 Ok(())
6412 } else {
6413 Err(crate::gasp::GASPError::StorageError(
6416 "a finalize submit did not land".to_string(),
6417 ))
6418 }
6419 }
6420}
6421
6422#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6426pub struct GASPSyncResult {
6427 #[serde(default, flatten)]
6428 pub cost: crate::meter::SyncCost,
6429 #[serde(default)]
6430 pub filter: crate::sync_load::SyncFilter,
6431 #[serde(default)]
6432 pub topics_filtered: Vec<String>,
6433 pub topics_synced: HashMap<String, TopicSyncResult>,
6435}
6436
6437#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6439pub struct TopicSyncResult {
6440 #[serde(default)]
6441 pub peer_results: HashMap<String, crate::backfill::PeerSyncResult>,
6442 #[serde(default)]
6443 pub dead_graphs: u64,
6444 #[serde(default)]
6445 pub expired_swept: u64,
6446 #[serde(default)]
6447 pub memo_folded: u64,
6448 #[serde(default, flatten)]
6449 pub cost: crate::meter::SyncCost,
6450 #[serde(default)]
6451 pub root_bounds: Vec<crate::sync_load::RootBound>,
6452 #[serde(default)]
6453 pub peers_skipped: Vec<crate::sync_load::SkippedPeer>,
6454 #[serde(default)]
6455 pub peers_filtered: Vec<String>,
6456 #[serde(default)]
6458 pub roots_proven: u64,
6459 #[serde(default)]
6460 pub roots_unproven: u64,
6461 #[serde(default)]
6463 pub cursor_held_by: Vec<String>,
6464 #[serde(default)]
6466 pub expired_refused: u64,
6467 pub peers: Vec<String>,
6469 pub sync_type: String,
6471 pub errors: Vec<String>,
6477 #[serde(default)]
6483 pub pruned_inputs: u64,
6484 #[serde(default)]
6489 pub discarded_graphs: u64,
6490 #[serde(default)]
6496 pub anchor_walk_held_graphs: u64,
6497 #[serde(default)]
6507 pub finalized_graphs: u64,
6508 #[serde(default)]
6516 pub deadline_dropped_graphs: u64,
6517 #[serde(default)]
6522 pub cursor_moves: Vec<CursorMove>,
6523 #[serde(default)]
6527 pub deferred_graphs: u64,
6528 #[serde(default)]
6530 pub resumed_graphs: u64,
6531 #[serde(default)]
6534 pub converged_graphs: u64,
6535 #[serde(default)]
6537 pub dropped_graphs: Vec<DroppedDeferral>,
6538 #[serde(default)]
6543 pub stalled_graphs: u64,
6544 #[serde(default)]
6548 pub held_back_graphs: u64,
6549 #[serde(default)]
6554 pub backed_off_graphs: u64,
6555 #[serde(default)]
6558 pub waiting_graphs: u64,
6559 #[serde(default)]
6564 pub idle_counted: u64,
6565 #[serde(default)]
6571 pub unanchorable_graphs: u64,
6572 #[serde(default)]
6578 pub absent_struck: u64,
6579 #[serde(default)]
6584 pub route_faults: Vec<crate::route_fault::RouteFaultSeen>,
6585 #[serde(default)]
6588 pub unanchorable_cleared: u64,
6589 #[serde(default)]
6594 pub strikes_refused: u64,
6595 #[serde(default)]
6601 pub faulted_roots: Vec<crate::gasp::FaultedRoot>,
6602}
6603
6604#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6607pub struct DroppedDeferral {
6608 pub peer: String,
6610 pub outpoint: String,
6612 pub reason: String,
6614}
6615
6616#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6619pub struct CursorMove {
6620 pub peer: String,
6622 pub from: u64,
6624 pub to: u64,
6626}
6627
6628pub(crate) fn current_timestamp_ms() -> f64 {
6633 #[cfg(target_arch = "wasm32")]
6634 {
6635 js_sys::Date::now()
6636 }
6637 #[cfg(not(target_arch = "wasm32"))]
6638 {
6639 std::time::SystemTime::now()
6640 .duration_since(std::time::UNIX_EPOCH)
6641 .map_or(0.0, |d| d.as_millis() as f64)
6642 }
6643}
6644
6645fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
6651 parse_advertisement_domain_from_script(output_script, b"SHIP", None)
6652}
6653
6654fn parse_lookup_advertisement(
6655 output: &OutputListItem,
6656 protocol: &[u8],
6657 scope: &str,
6658) -> Option<String> {
6659 let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
6660 .ok()?;
6661 let script = tx
6662 .outputs
6663 .get(output.output_index as usize)?
6664 .locking_script
6665 .to_binary();
6666 parse_advertisement_domain_from_script(&script, protocol, Some(scope))
6667}
6668
6669fn parse_advertisement_domain_from_script(
6670 output_script: &[u8],
6671 protocol: &[u8],
6672 scope: Option<&str>,
6673) -> Option<String> {
6674 use bsv_rs::script::templates::PushDrop;
6675
6676 let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
6677 let pushdrop = PushDrop::decode(&script.into()).ok()?;
6678
6679 if pushdrop.fields.len() < 3 {
6680 return None;
6681 }
6682
6683 if pushdrop.fields[0] != protocol {
6684 return None;
6685 }
6686
6687 if scope.is_some_and(|expected| {
6688 pushdrop
6689 .fields
6690 .get(3)
6691 .is_none_or(|actual| actual != expected.as_bytes())
6692 }) {
6693 return None;
6694 }
6695
6696 let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
6697 if domain.is_empty() {
6698 return None;
6699 }
6700
6701 Some(domain)
6702}
6703
6704#[derive(Debug, thiserror::Error)]
6713pub enum EngineError {
6714 #[error("This server does not support this topic: {0}")]
6715 UnsupportedTopic(String),
6716
6717 #[error("Lookup service not found for provider: {0}")]
6718 LookupServiceNotFound(String),
6719
6720 #[error("No matching output found!")]
6723 NodeNotFound,
6724
6725 #[error("lookup failed: {0}")]
6726 LookupFailed(String),
6727
6728 #[error("invalid query: {0}")]
6735 InvalidQuery(String),
6736
6737 #[error("storage error: {0}")]
6738 StorageError(String),
6739
6740 #[error("broadcast error: {0}")]
6741 BroadcastError(String),
6742
6743 #[error("SPV verification failed: {0}")]
6744 SpvError(String),
6745
6746 #[error("BEEF parsing failed: {0}")]
6747 BeefParseError(String),
6748
6749 #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6759 ScriptVerificationFailed {
6760 subject_txid: String,
6761 input_index: u32,
6762 reason: String,
6763 },
6764
6765 #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6772 ScriptWalkInconclusive {
6773 at_txid: String,
6774 subject_judged: bool,
6775 reason: String,
6776 },
6777
6778 #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6783 ScriptWalkOverBudget {
6784 at_txid: String,
6785 subject_judged: bool,
6786 limb: DoorLimb,
6787 what: String,
6788 },
6789
6790 #[error(
6800 "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6801 .0.limb.as_str(),
6802 .0.what
6803 )]
6804 WalkCouldNotRun(WalkCouldNotRun),
6805
6806 #[error("{0}")]
6807 Other(String),
6808}
6809
6810impl From<StorageError> for EngineError {
6811 fn from(e: StorageError) -> Self {
6812 EngineError::StorageError(e.to_string())
6813 }
6814}
6815
6816impl From<TopicManagerError> for EngineError {
6817 fn from(e: TopicManagerError) -> Self {
6818 EngineError::Other(e.to_string())
6819 }
6820}
6821
6822impl From<LookupServiceError> for EngineError {
6823 fn from(e: LookupServiceError) -> Self {
6824 EngineError::LookupFailed(e.to_string())
6825 }
6826}
6827
6828impl From<AdvertiserError> for EngineError {
6829 fn from(e: AdvertiserError) -> Self {
6830 EngineError::Other(e.to_string())
6831 }
6832}
6833
6834pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6844 let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6845 order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6846 order
6847}
6848
6849#[cfg(test)]
6850mod tests {
6851 use super::*;
6852 use crate::lookup_service::LookupService as LookupServiceTrait;
6853 use crate::storage::memory::MemoryStorage;
6854 use crate::topic_manager::TopicManager as TopicManagerTrait;
6855 use async_trait::async_trait;
6856 use std::sync::Mutex;
6857
6858 #[test]
6862 fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6863 let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6864 let config: SyncConfiguration = HashMap::from([
6865 ("tm_a".to_string(), SyncTarget::Ship),
6866 ("tm_z".to_string(), peers("https://z")),
6867 ("tm_b".to_string(), SyncTarget::Ship),
6868 ("tm_m".to_string(), peers("https://m")),
6869 ("tm_off".to_string(), SyncTarget::Disabled),
6870 ]);
6871 let order: Vec<&str> = sync_order(&config)
6872 .iter()
6873 .map(|(t, _)| t.as_str())
6874 .collect();
6875 assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6876 }
6877
6878 struct MockTopicManager {
6881 admit_indices: Vec<u32>,
6882 }
6883
6884 impl MockTopicManager {
6885 fn admitting(indices: Vec<u32>) -> Self {
6886 Self {
6887 admit_indices: indices,
6888 }
6889 }
6890 }
6891
6892 #[async_trait(?Send)]
6893 impl TopicManagerTrait for MockTopicManager {
6894 async fn identify_admissible_outputs(
6895 &self,
6896 _tx: &Transaction,
6897 _previous_coins: &[u8],
6898 _off_chain_values: Option<&[u8]>,
6899 _mode: SubmitMode,
6900 _context: &TopicAdmittanceContext,
6901 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6902 Ok(AdmittanceInstructions {
6903 outputs_to_admit: self.admit_indices.clone(),
6904 coins_to_retain: vec![],
6905 coins_removed: None,
6906 })
6907 }
6908
6909 async fn get_documentation(&self) -> String {
6910 "Mock topic manager".to_string()
6911 }
6912
6913 async fn get_metadata(&self) -> ServiceMetadata {
6914 ServiceMetadata {
6915 name: "mock-tm".to_string(),
6916 description: Some("Mock for testing".to_string()),
6917 ..Default::default()
6918 }
6919 }
6920 }
6921
6922 struct MockLookupService {
6925 records: Mutex<Vec<UTXOReference>>,
6926 }
6927
6928 impl MockLookupService {
6929 fn new() -> Self {
6930 Self {
6931 records: Mutex::new(Vec::new()),
6932 }
6933 }
6934 }
6935
6936 #[async_trait(?Send)]
6937 impl LookupServiceTrait for MockLookupService {
6938 fn admission_mode(&self) -> AdmissionMode {
6939 AdmissionMode::LockingScript
6940 }
6941
6942 fn spend_notification_mode(&self) -> SpendNotificationMode {
6943 SpendNotificationMode::None
6944 }
6945
6946 async fn output_admitted_by_topic(
6947 &self,
6948 payload: &OutputAdmittedByTopic,
6949 ) -> Result<(), LookupServiceError> {
6950 let (txid, oi) = match payload {
6951 OutputAdmittedByTopic::LockingScript {
6952 txid, output_index, ..
6953 } => (txid.clone(), *output_index),
6954 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
6955 ("whole".into(), *output_index)
6956 }
6957 };
6958 self.records.lock().unwrap().push(UTXOReference {
6959 txid,
6960 output_index: oi,
6961 });
6962 Ok(())
6963 }
6964
6965 async fn output_evicted(
6966 &self,
6967 txid: &str,
6968 output_index: u32,
6969 ) -> Result<(), LookupServiceError> {
6970 self.records
6971 .lock()
6972 .unwrap()
6973 .retain(|r| !(r.txid == txid && r.output_index == output_index));
6974 Ok(())
6975 }
6976
6977 async fn lookup(
6978 &self,
6979 _question: &LookupQuestion,
6980 ) -> Result<LookupResult, LookupServiceError> {
6981 Ok(LookupResult::OutputList(
6982 self.records.lock().unwrap().clone(),
6983 ))
6984 }
6985
6986 async fn get_documentation(&self) -> String {
6987 "Mock lookup service".to_string()
6988 }
6989
6990 async fn get_metadata(&self) -> ServiceMetadata {
6991 ServiceMetadata {
6992 name: "mock-ls".to_string(),
6993 ..Default::default()
6994 }
6995 }
6996 }
6997
6998 const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
7002 const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
7003
7004 fn decode_hex(hex: &str) -> Vec<u8> {
7005 (0..hex.len())
7006 .step_by(2)
7007 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
7008 .collect()
7009 }
7010
7011 fn test_beef() -> Vec<u8> {
7012 decode_hex(TEST_BEEF_HEX)
7013 }
7014
7015 fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
7016 TaggedBEEF::new(
7017 test_beef(),
7018 topics.into_iter().map(str::to_string).collect(),
7019 )
7020 }
7021
7022 fn make_engine(admit_indices: Vec<u32>) -> Engine {
7025 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7026 managers.insert(
7027 "tm_test".to_string(),
7028 Box::new(MockTopicManager::admitting(admit_indices)),
7029 );
7030
7031 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7032 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7033
7034 let storage = Box::new(MemoryStorage::new());
7035
7036 Engine::new(
7037 managers,
7038 lookup_services,
7039 storage,
7040 None,
7041 EngineConfig::default(),
7042 )
7043 }
7044
7045 #[tokio::test]
7054 async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
7055 use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
7056 use std::cell::RefCell;
7057 use std::rc::Rc;
7058
7059 let parsed = Beef::from_binary(&test_beef()).unwrap();
7062 let parent = parsed.txs[0].tx().unwrap().clone();
7063 let child = parsed.txs[1].tx().unwrap().clone();
7064 let child_id = child.id();
7065 let parent_id = parent.id();
7066 let mut beef = Beef::new();
7067 for bump in &parsed.bumps {
7073 beef.merge_bump(bump.clone());
7074 }
7075 beef.merge_transaction(child);
7076 beef.merge_transaction(parent);
7077 let mut w = bsv_rs::primitives::encoding::Writer::new();
7078 w.write_u32_le(ATOMIC_BEEF);
7079 let mut le = hex::decode(&child_id).unwrap();
7080 le.reverse();
7081 w.write_bytes(&le);
7082 beef.to_writer(&mut w);
7083 let atomic_wire_order = w.into_bytes();
7084
7085 struct RecordingTm(Rc<RefCell<Option<String>>>);
7087 #[async_trait(?Send)]
7088 impl TopicManagerTrait for RecordingTm {
7089 async fn identify_admissible_outputs(
7090 &self,
7091 tx: &Transaction,
7092 _previous_coins: &[u8],
7093 _off_chain_values: Option<&[u8]>,
7094 _mode: SubmitMode,
7095 _context: &TopicAdmittanceContext,
7096 ) -> Result<AdmittanceInstructions, TopicManagerError> {
7097 *self.0.borrow_mut() = Some(tx.id());
7098 Ok(AdmittanceInstructions {
7099 outputs_to_admit: vec![0],
7100 coins_to_retain: vec![],
7101 coins_removed: None,
7102 })
7103 }
7104 async fn get_documentation(&self) -> String {
7105 String::new()
7106 }
7107 async fn get_metadata(&self) -> ServiceMetadata {
7108 ServiceMetadata {
7109 name: "tm_test".to_string(),
7110 ..Default::default()
7111 }
7112 }
7113 }
7114
7115 let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
7116 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7117 managers.insert(
7118 "tm_test".to_string(),
7119 Box::new(RecordingTm(Rc::clone(&seen))),
7120 );
7121 let engine = Engine::new(
7122 managers,
7123 HashMap::new(),
7124 Box::new(MemoryStorage::new()),
7125 None,
7126 EngineConfig::default(),
7127 );
7128
7129 let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
7130 let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
7131
7132 let handed = seen.borrow().clone().expect("TM was not invoked");
7133 assert_eq!(
7134 handed, child_id,
7135 "TM must be handed the atomic subject, got the wire-order pick"
7136 );
7137 assert_ne!(handed, parent_id);
7138 }
7139
7140 #[tokio::test]
7153 async fn history_hydration_preserves_the_stored_proof() {
7154 use bsv_rs::transaction::Beef;
7155
7156 let engine = make_engine(vec![0]);
7157 let stored = test_beef();
7158 let bumps_before = Beef::from_binary(&stored)
7159 .expect("fixture BEEF parses")
7160 .bumps
7161 .len();
7162 assert!(
7163 bumps_before > 0,
7164 "fixture must actually carry a proof, or this cell proves nothing"
7165 );
7166
7167 let output = Output {
7168 txid: TEST_TXID.to_string(),
7169 output_index: 0,
7170 output_script: vec![0x76, 0xa9],
7171 satoshis: 1000,
7172 topic: "tm_test".to_string(),
7173 spent: false,
7174 outputs_consumed: vec![],
7177 consumed_by: vec![],
7178 beef: Some(stored.clone()),
7179 block_height: None,
7180 score: Some(1000.0),
7181 };
7182
7183 let hydrated = engine
7184 .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
7185 .await
7186 .expect("hydration must not error")
7187 .expect("hydration must return the output");
7188 let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
7189 .expect("hydrated BEEF parses");
7190
7191 assert_eq!(
7192 beef.bumps.len(),
7193 bumps_before,
7194 "asking for history must not strip the merkle proof (issue #4)"
7195 );
7196 }
7197
7198 #[tokio::test]
7199 async fn test_submit_unsupported_topic_errors() {
7200 let engine = make_engine(vec![0]);
7201 let beef = test_tagged_beef(vec!["tm_nonexistent"]);
7202
7203 let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
7204 assert!(result.is_err());
7205 assert!(matches!(
7206 result.unwrap_err(),
7207 EngineError::UnsupportedTopic(_)
7208 ));
7209 }
7210
7211 #[tokio::test]
7212 async fn test_submit_admits_outputs() {
7213 let engine = make_engine(vec![0, 1]);
7214 let beef = test_tagged_beef(vec!["tm_test"]);
7215
7216 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7217
7218 let instructions = steak.get("tm_test").unwrap();
7220 assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
7221
7222 let txid = TEST_TXID;
7224 let found = engine
7225 .storage()
7226 .find_output(txid, 0, Some("tm_test"), None, true)
7227 .await
7228 .unwrap()
7229 .expect("Output 0 should be in storage");
7230
7231 assert_eq!(found.txid, TEST_TXID);
7232 assert_eq!(found.satoshis, 26172); assert!(
7234 !found.output_script.is_empty(),
7235 "Script should be populated"
7236 );
7237 assert!(found.beef.is_some(), "BEEF should be stored");
7238 assert!(!found.spent);
7239 }
7240
7241 #[tokio::test]
7242 async fn test_submit_duplicate_is_skipped() {
7243 let engine = make_engine(vec![0]);
7244 let beef = test_tagged_beef(vec!["tm_test"]);
7245
7246 let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7248 assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
7249
7250 let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7252 assert!(steak2["tm_test"].outputs_to_admit.is_empty());
7253 }
7254
7255 #[tokio::test]
7256 async fn test_submit_notifies_lookup_service() {
7257 let engine = make_engine(vec![0]);
7258 let beef = test_tagged_beef(vec!["tm_test"]);
7259
7260 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7261
7262 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7264 let answer = engine.lookup(&question, None).await.unwrap();
7265
7266 match answer {
7267 LookupAnswer::OutputList { outputs } => {
7268 assert_eq!(outputs.len(), 1);
7269 }
7270 _ => panic!("Expected OutputList"),
7271 }
7272 }
7273
7274 #[tokio::test]
7278 async fn test_lookup_with_txids_aligns_txids_with_outputs() {
7279 let engine = make_engine(vec![0]);
7280 let beef = test_tagged_beef(vec!["tm_test"]);
7281 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7282
7283 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7284 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7285 match answer {
7286 LookupAnswer::OutputList { outputs } => {
7287 assert_eq!(outputs.len(), 1);
7288 assert_eq!(txids.len(), outputs.len(), "one txid per output");
7289 assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
7290 }
7291 _ => panic!("Expected OutputList"),
7292 }
7293 }
7294
7295 #[tokio::test]
7296 async fn proof_window_holds_an_aged_child_with_a_proven_parent() {
7297 let mut engine = make_engine(vec![0]);
7298 let storage = std::rc::Rc::new(MemoryStorage::new());
7299 engine.storage = Box::new(storage.clone());
7300 engine
7301 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7302 .await
7303 .unwrap();
7304 assert!(beef_limits::own_proof(&test_beef(), TEST_TXID)
7305 .unwrap()
7306 .is_none());
7307 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7308 storage.advance_clock((crate::proof_window::PROOFLESS_FLAG_MS / 1000) as u64);
7309 assert_eq!(
7310 engine
7311 .lookup_with_txids(&question, None)
7312 .await
7313 .unwrap()
7314 .1
7315 .len(),
7316 1
7317 );
7318 storage.advance_clock(1);
7319 assert!(
7320 engine
7321 .lookup_with_txids(&question, None)
7322 .await
7323 .unwrap()
7324 .1
7325 .is_empty(),
7326 "an aged unproven child must be held even when its parent is proven"
7327 );
7328 }
7329
7330 #[tokio::test]
7331 async fn proof_window_peer_proof_verifies_then_restores_lookup() {
7332 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7333 let mut engine = make_engine(vec![0]);
7334 let storage = std::rc::Rc::new(MemoryStorage::new());
7335 engine.storage = Box::new(storage.clone());
7336 engine
7337 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7338 .await
7339 .unwrap();
7340 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7341 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7342 let candidate = HeldOutput {
7343 txid: TEST_TXID.into(),
7344 output_index: 0,
7345 topic: "tm_test".into(),
7346 };
7347 let (_, mut remote) = budget_page_engine(1);
7348 remote.node_cost_ms = 0;
7349 remote.nodes = std::rc::Rc::new(HashMap::from([(
7350 TEST_TXID.into(),
7351 crate::types::GASPNode {
7352 graph_id: String::new(),
7353 raw_tx: String::new(),
7354 output_index: 0,
7355 proof: Some(single_leaf_bump_hex(TEST_TXID, 800_000)),
7356 tx_metadata: None,
7357 output_metadata: None,
7358 inputs: None,
7359 },
7360 )]));
7361 engine
7362 .config
7363 .sync_configuration
7364 .insert("tm_test".into(), SyncTarget::Ship);
7365 engine.ship_bootstrap_peers.insert(
7366 "tm_test".into(),
7367 vec!["https://a.example".into(), "https://b.example".into()],
7368 );
7369 engine.set_gasp_remote_factory(Box::new(remote));
7370 let progress = std::cell::Cell::new(PeerProofSummary::default());
7371 engine.chain_tracker = Some(Box::new(bsv_rs::transaction::MockChainTracker::new(
7372 800_001,
7373 )));
7374 engine
7375 .rerequest_held_proofs(std::slice::from_ref(&candidate), &progress)
7376 .await;
7377 assert_eq!(progress.get().requests, 2, "ask every topic peer");
7378 assert_eq!(progress.get().landed, 0);
7379 assert_eq!(progress.get().refused, 2);
7380 assert!(engine
7381 .lookup_with_txids(&question, None)
7382 .await
7383 .unwrap()
7384 .1
7385 .is_empty());
7386 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7387 tracker.add_root(800_000, TEST_TXID.into());
7388 engine.chain_tracker = Some(Box::new(tracker));
7389 engine.rerequest_held_proofs(&[candidate], &progress).await;
7390 assert_eq!(progress.get().requests, 4);
7391 assert_eq!(
7392 progress.get().landed,
7393 1,
7394 "count durable proofs, not peer answers"
7395 );
7396 let (_, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7397 assert_eq!(txids, vec![TEST_TXID]);
7398 let stored = storage
7399 .find_output(TEST_TXID, 0, None, None, true)
7400 .await
7401 .unwrap()
7402 .unwrap();
7403 assert!(
7404 beef_limits::own_proof(stored.beef.as_ref().unwrap(), TEST_TXID)
7405 .unwrap()
7406 .is_some()
7407 );
7408 assert_eq!(storage.proven_at_height(TEST_TXID), Some(Some(800_000)));
7409 }
7410
7411 #[tokio::test]
7412 async fn proof_window_peer_hash_leaf_is_stored_as_own_proof() {
7413 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7414 let mut engine = make_engine(vec![0]);
7415 let storage = std::rc::Rc::new(MemoryStorage::new());
7416 engine.storage = Box::new(storage.clone());
7417 engine
7418 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7419 .await
7420 .unwrap();
7421 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7422 let mut proof =
7423 bsv_rs::transaction::MerklePath::from_hex(&single_leaf_bump_hex(TEST_TXID, 800_000))
7424 .unwrap();
7425 proof.path[0][0].txid = false;
7426 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7427 tracker.add_root(800_000, proof.compute_root(Some(TEST_TXID)).unwrap());
7428 engine.chain_tracker = Some(Box::new(tracker));
7429 let (_, mut remote) = budget_page_engine(1);
7430 remote.node_cost_ms = 0;
7431 remote.nodes = std::rc::Rc::new(HashMap::from([(
7432 TEST_TXID.into(),
7433 crate::types::GASPNode {
7434 graph_id: String::new(),
7435 raw_tx: String::new(),
7436 output_index: 0,
7437 proof: Some(proof.to_hex()),
7438 tx_metadata: None,
7439 output_metadata: None,
7440 inputs: None,
7441 },
7442 )]));
7443 engine.config.sync_configuration.insert(
7444 "tm_test".into(),
7445 SyncTarget::Peers(vec!["https://peer.example".into()]),
7446 );
7447 engine.set_gasp_remote_factory(Box::new(remote));
7448 let progress = std::cell::Cell::new(PeerProofSummary::default());
7449 engine
7450 .rerequest_held_proofs(
7451 &[HeldOutput {
7452 txid: TEST_TXID.into(),
7453 output_index: 0,
7454 topic: "tm_test".into(),
7455 }],
7456 &progress,
7457 )
7458 .await;
7459 assert_eq!(progress.get().landed, 1);
7460 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7461 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7462 assert_eq!(txids, vec![TEST_TXID]);
7463 let LookupAnswer::OutputList { outputs } = answer else {
7464 panic!("expected outputs")
7465 };
7466 assert!(
7467 beef_limits::own_proof(&outputs[0].beef, TEST_TXID)
7468 .unwrap()
7469 .is_some(),
7470 "a proven flag must have the child's own path in the served BEEF"
7471 );
7472 }
7473
7474 #[tokio::test]
7475 async fn proof_window_silent_peers_yield_at_the_request_budget() {
7476 use crate::proof_window::{HeldOutput, PeerProofSummary, PEER_PROOF_REQUEST_MS};
7477 let (mut engine, remote) = budget_page_engine(1);
7478 engine.config.sync_configuration.insert(
7479 "tm_test".into(),
7480 SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
7481 );
7482 let elapsed = remote.elapsed.clone();
7483 engine.set_peer_sync_budget(
7484 std::rc::Rc::new(move |ms| {
7485 assert_eq!(ms, PEER_PROOF_REQUEST_MS);
7486 let deadline = elapsed.get() + ms;
7487 let elapsed = elapsed.clone();
7488 Box::pin(std::future::poll_fn(move |_| {
7489 if elapsed.get() >= deadline {
7490 std::task::Poll::Ready(())
7491 } else {
7492 std::task::Poll::Pending
7493 }
7494 }))
7495 }),
7496 30_000,
7497 );
7498 let candidate = HeldOutput {
7499 txid: remote.outputs[0].txid.clone(),
7500 output_index: 0,
7501 topic: "tm_test".into(),
7502 };
7503 engine.set_gasp_remote_factory(Box::new(remote));
7504 let progress = std::cell::Cell::new(PeerProofSummary::default());
7505 engine.rerequest_held_proofs(&[candidate], &progress).await;
7506 assert_eq!(progress.get().requests, 2);
7507 assert_eq!(progress.get().faults, 2);
7508 assert_eq!(progress.get().landed, 0);
7509 }
7510
7511 #[test]
7513 fn sync_limit_clamp() {
7514 assert_eq!(
7515 Engine::clamp_sync_limit(None),
7516 Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
7517 "absent limit gets the default page, never unbounded"
7518 );
7519 assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
7520 assert_eq!(
7521 Engine::clamp_sync_limit(Some(u64::MAX)),
7522 Engine::SYNC_RESPONSE_MAX_LIMIT,
7523 "a huge caller-supplied limit is capped"
7524 );
7525 }
7526
7527 #[tokio::test]
7528 async fn test_lookup_unknown_service_errors() {
7529 let engine = make_engine(vec![]);
7530 let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
7531
7532 let result = engine.lookup(&question, None).await;
7533 assert!(result.is_err());
7534 assert!(matches!(
7535 result.unwrap_err(),
7536 EngineError::LookupServiceNotFound(_)
7537 ));
7538 }
7539
7540 #[tokio::test]
7541 async fn test_list_topic_managers() {
7542 let engine = make_engine(vec![]);
7543 let managers = engine.list_topic_managers().await;
7544
7545 assert_eq!(managers.len(), 1);
7546 assert!(managers.contains_key("tm_test"));
7547 assert_eq!(managers["tm_test"].name, "mock-tm");
7548 }
7549
7550 #[tokio::test]
7551 async fn test_list_lookup_service_providers() {
7552 let engine = make_engine(vec![]);
7553 let services = engine.list_lookup_service_providers().await;
7554
7555 assert_eq!(services.len(), 1);
7556 assert!(services.contains_key("ls_test"));
7557 assert_eq!(services["ls_test"].name, "mock-ls");
7558 }
7559
7560 #[tokio::test]
7561 async fn test_get_documentation() {
7562 let engine = make_engine(vec![]);
7563
7564 let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
7565 assert_eq!(tm_docs, "Mock topic manager");
7566
7567 let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
7568 assert_eq!(ls_docs, "Mock lookup service");
7569
7570 let missing = engine
7571 .get_documentation_for_topic_manager("tm_missing")
7572 .await;
7573 assert_eq!(missing, "No documentation found!");
7574 }
7575
7576 #[tokio::test]
7577 async fn test_provide_foreign_sync_response() {
7578 let engine = make_engine(vec![0]);
7579
7580 let beef = test_tagged_beef(vec!["tm_test"]);
7582 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7583
7584 let request = GASPInitialRequest {
7586 version: 1,
7587 since: 0,
7588 limit: Some(100),
7589 };
7590 let response = engine
7591 .provide_foreign_sync_response(&request, "tm_test")
7592 .await
7593 .unwrap();
7594
7595 assert_eq!(response.utxo_list.len(), 1);
7597 assert_eq!(response.utxo_list[0].txid, TEST_TXID);
7598 assert_eq!(response.since, 0);
7599 }
7600
7601 #[tokio::test]
7602 async fn test_delete_utxo_deep_simple() {
7603 let engine = make_engine(vec![0]);
7604 let beef = test_tagged_beef(vec!["tm_test"]);
7605
7606 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7607
7608 let txid = TEST_TXID;
7609 let output = engine
7610 .storage()
7611 .find_output(txid, 0, Some("tm_test"), None, false)
7612 .await
7613 .unwrap()
7614 .unwrap();
7615
7616 engine.delete_utxo_deep(&output, None).await.unwrap();
7618
7619 let found = engine
7621 .storage()
7622 .find_output(txid, 0, Some("tm_test"), None, false)
7623 .await
7624 .unwrap();
7625 assert!(found.is_none());
7626 }
7627
7628 #[tokio::test]
7629 async fn test_sync_config_defaults_to_ship() {
7630 let engine = make_engine(vec![]);
7631 let sync = &engine.config.sync_configuration;
7632
7633 assert!(sync.contains_key("tm_test"));
7635 assert!(matches!(sync["tm_test"], SyncTarget::Ship));
7636 }
7637
7638 #[tokio::test]
7639 async fn test_multiple_topics_in_single_submit() {
7640 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7641 managers.insert(
7642 "tm_alpha".to_string(),
7643 Box::new(MockTopicManager::admitting(vec![0])),
7644 );
7645 managers.insert(
7646 "tm_beta".to_string(),
7647 Box::new(MockTopicManager::admitting(vec![1])),
7648 );
7649
7650 let engine = Engine::new(
7651 managers,
7652 HashMap::new(),
7653 Box::new(MemoryStorage::new()),
7654 None,
7655 EngineConfig::default(),
7656 );
7657
7658 let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
7659
7660 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7661
7662 assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
7663 assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
7664 }
7665
7666 #[tokio::test]
7667 async fn test_evict_output_with_topic() {
7668 let engine = make_engine(vec![0]);
7669 let beef = test_tagged_beef(vec!["tm_test"]);
7670 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7671
7672 let found = engine
7674 .storage()
7675 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7676 .await
7677 .unwrap();
7678 assert!(found.is_some(), "Output should exist before eviction");
7679
7680 engine
7682 .evict_output(TEST_TXID, 0, Some("tm_test"))
7683 .await
7684 .unwrap();
7685
7686 let found = engine
7688 .storage()
7689 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7690 .await
7691 .unwrap();
7692 assert!(found.is_none(), "Output should be gone after eviction");
7693
7694 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7696 let answer = engine.lookup(&question, None).await.unwrap();
7697 match answer {
7698 LookupAnswer::OutputList { outputs } => {
7699 assert!(
7700 outputs.is_empty(),
7701 "Lookup should return empty after eviction"
7702 );
7703 }
7704 _ => panic!("Expected OutputList"),
7705 }
7706 }
7707
7708 #[tokio::test]
7709 async fn test_evict_output_without_topic() {
7710 let engine = make_engine(vec![0]);
7711 let beef = test_tagged_beef(vec!["tm_test"]);
7712 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7713
7714 engine.evict_output(TEST_TXID, 0, None).await.unwrap();
7716
7717 let found = engine
7718 .storage()
7719 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7720 .await
7721 .unwrap();
7722 assert!(
7723 found.is_none(),
7724 "Output should be gone after topic-less eviction"
7725 );
7726 }
7727
7728 #[tokio::test]
7729 async fn test_evict_nonexistent_output_is_ok() {
7730 let engine = make_engine(vec![]);
7731
7732 let result = engine
7734 .evict_output("nonexistent_txid", 99, Some("tm_test"))
7735 .await;
7736 assert!(result.is_ok(), "Evicting nonexistent output should succeed");
7737 }
7738
7739 use crate::broadcaster::Broadcaster;
7742 use std::sync::Arc;
7743
7744 type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
7745
7746 struct MockBroadcaster {
7747 calls: BroadcastCallLog,
7748 }
7749
7750 impl MockBroadcaster {
7751 fn new() -> Self {
7752 Self {
7753 calls: Arc::new(Mutex::new(Vec::new())),
7754 }
7755 }
7756
7757 #[allow(dead_code, reason = "kept for future test extension")]
7758 fn call_count(&self) -> usize {
7759 self.calls.lock().unwrap().len()
7760 }
7761
7762 #[allow(dead_code, reason = "kept for future test extension")]
7763 fn calls(&self) -> Vec<(String, Vec<String>)> {
7764 self.calls.lock().unwrap().clone()
7765 }
7766 }
7767
7768 #[async_trait(?Send)]
7769 impl Broadcaster for MockBroadcaster {
7770 async fn broadcast_to_host(
7771 &self,
7772 host_url: &str,
7773 tagged_beef: &TaggedBEEF,
7774 ) -> Result<(), String> {
7775 self.calls
7776 .lock()
7777 .unwrap()
7778 .push((host_url.to_string(), tagged_beef.topics.clone()));
7779 Ok(())
7780 }
7781 }
7782
7783 struct MockSHIPLookupService {
7789 records: Mutex<HashMap<String, Vec<UTXOReference>>>,
7791 }
7792
7793 impl MockSHIPLookupService {
7794 fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
7795 Self {
7796 records: Mutex::new(records),
7797 }
7798 }
7799 }
7800
7801 #[async_trait(?Send)]
7802 impl LookupServiceTrait for MockSHIPLookupService {
7803 fn admission_mode(&self) -> AdmissionMode {
7804 AdmissionMode::LockingScript
7805 }
7806
7807 fn spend_notification_mode(&self) -> SpendNotificationMode {
7808 SpendNotificationMode::None
7809 }
7810
7811 async fn output_admitted_by_topic(
7812 &self,
7813 _payload: &OutputAdmittedByTopic,
7814 ) -> Result<(), LookupServiceError> {
7815 Ok(())
7816 }
7817
7818 async fn output_evicted(
7819 &self,
7820 _txid: &str,
7821 _output_index: u32,
7822 ) -> Result<(), LookupServiceError> {
7823 Ok(())
7824 }
7825
7826 async fn lookup(
7827 &self,
7828 question: &LookupQuestion,
7829 ) -> Result<LookupResult, LookupServiceError> {
7830 if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
7832 let records = self.records.lock().unwrap();
7833 let mut results = Vec::new();
7834 for topic_val in topics {
7835 if let Some(topic) = topic_val.as_str() {
7836 if let Some(refs) = records.get(topic) {
7837 results.extend(refs.iter().cloned());
7838 }
7839 }
7840 }
7841 return Ok(LookupResult::OutputList(results));
7842 }
7843 Ok(LookupResult::OutputList(vec![]))
7844 }
7845
7846 async fn get_documentation(&self) -> String {
7847 "Mock SHIP lookup service".to_string()
7848 }
7849
7850 async fn get_metadata(&self) -> ServiceMetadata {
7851 ServiceMetadata {
7852 name: "mock-ls-ship".to_string(),
7853 ..Default::default()
7854 }
7855 }
7856 }
7857
7858 fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
7860 use bsv_rs::script::templates::PushDrop;
7861 use bsv_rs::PublicKey;
7862
7863 let fields = vec![
7864 b"SHIP".to_vec(),
7865 vec![0x02; 33], domain.as_bytes().to_vec(),
7867 topic.as_bytes().to_vec(),
7868 ];
7869
7870 let pubkey = PublicKey::from_hex(
7873 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7874 )
7875 .expect("valid test pubkey");
7876
7877 let pd = PushDrop::new(pubkey, fields);
7878 pd.lock().to_binary()
7879 }
7880
7881 async fn make_engine_with_broadcaster(
7884 admit_indices: Vec<u32>,
7885 broadcaster: MockBroadcaster,
7886 ship_domains: Vec<(&str, &str)>,
7887 hosting_url: Option<&str>,
7888 ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
7889 let calls = broadcaster.calls.clone();
7890
7891 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7892 managers.insert(
7893 "tm_test".to_string(),
7894 Box::new(MockTopicManager::admitting(admit_indices)),
7895 );
7896
7897 let storage = Box::new(MemoryStorage::new());
7898
7899 let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
7903
7904 for (domain, topic) in &ship_domains {
7905 let fake_txid = format!("{:064x}", {
7907 let mut h: u64 = 0;
7908 for b in domain.bytes().chain(topic.bytes()) {
7909 h = h.wrapping_mul(31).wrapping_add(u64::from(b));
7910 }
7911 h
7912 });
7913
7914 ship_records
7916 .entry(topic.to_string())
7917 .or_default()
7918 .push(UTXOReference {
7919 txid: fake_txid.clone(),
7920 output_index: 0,
7921 });
7922
7923 let script = build_ship_pushdrop_script(domain, topic);
7925
7926 let output = Output {
7927 txid: fake_txid,
7928 output_index: 0,
7929 output_script: script,
7930 satoshis: 1,
7931 topic: "tm_ship".to_string(),
7932 spent: false,
7933 outputs_consumed: vec![],
7934 consumed_by: vec![],
7935 beef: None,
7936 block_height: None,
7937 score: None,
7938 };
7939
7940 storage.insert_output(&output).await.unwrap();
7941 }
7942
7943 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7944 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7945 lookup_services.insert(
7946 "ls_ship".to_string(),
7947 Box::new(MockSHIPLookupService::with_records(ship_records)),
7948 );
7949
7950 let config = EngineConfig {
7951 hosting_url: hosting_url.map(str::to_string),
7952 ..Default::default()
7953 };
7954
7955 let engine = Engine::with_chain_tracker(
7956 managers,
7957 lookup_services,
7958 storage,
7959 None,
7960 Some(Box::new(broadcaster)),
7961 None,
7962 config,
7963 );
7964
7965 (engine, calls)
7966 }
7967
7968 #[tokio::test]
7971 async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
7972 let broadcaster = MockBroadcaster::new();
7973 let (engine, calls) = make_engine_with_broadcaster(
7974 vec![0],
7975 broadcaster,
7976 vec![("https://peer1.example.com", "tm_test")],
7977 Some("https://self.example.com"),
7978 )
7979 .await;
7980
7981 let beef = test_tagged_beef(vec!["tm_test"]);
7982 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7983
7984 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7986
7987 let recorded = calls.lock().unwrap();
7989 assert_eq!(
7990 recorded.len(),
7991 1,
7992 "Broadcaster should be called once for the one SHIP peer"
7993 );
7994 assert_eq!(recorded[0].0, "https://peer1.example.com");
7995 }
7996
7997 #[tokio::test]
7998 async fn test_broadcaster_not_called_on_historical_tx() {
7999 let broadcaster = MockBroadcaster::new();
8000 let (engine, calls) = make_engine_with_broadcaster(
8001 vec![0],
8002 broadcaster,
8003 vec![("https://peer1.example.com", "tm_test")],
8004 None,
8005 )
8006 .await;
8007
8008 let beef = test_tagged_beef(vec!["tm_test"]);
8009 let steak = engine
8010 .submit(&beef, SubmitMode::HistoricalTx)
8011 .await
8012 .unwrap();
8013
8014 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8015
8016 assert_eq!(
8018 calls.lock().unwrap().len(),
8019 0,
8020 "Broadcaster should not be called for historical TX"
8021 );
8022 }
8023
8024 #[tokio::test]
8025 async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
8026 let broadcaster = MockBroadcaster::new();
8027 let (engine, calls) = make_engine_with_broadcaster(
8028 vec![0],
8029 broadcaster,
8030 vec![("https://peer1.example.com", "tm_test")],
8031 None,
8032 )
8033 .await;
8034
8035 let beef = test_tagged_beef(vec!["tm_test"]);
8036 let steak = engine
8037 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
8038 .await
8039 .unwrap();
8040
8041 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8042
8043 assert_eq!(
8044 calls.lock().unwrap().len(),
8045 0,
8046 "Broadcaster should not be called for historical-tx-no-spv"
8047 );
8048 }
8049
8050 #[tokio::test]
8051 async fn test_broadcaster_skips_self_hosting_url() {
8052 let broadcaster = MockBroadcaster::new();
8053 let (engine, calls) = make_engine_with_broadcaster(
8054 vec![0],
8055 broadcaster,
8056 vec![
8057 ("https://self.example.com", "tm_test"),
8058 ("https://peer2.example.com", "tm_test"),
8059 ],
8060 Some("https://self.example.com"),
8061 )
8062 .await;
8063
8064 let beef = test_tagged_beef(vec!["tm_test"]);
8065 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8066
8067 let recorded = calls.lock().unwrap();
8069 assert_eq!(
8070 recorded.len(),
8071 1,
8072 "Should broadcast to peer2 only, skipping self"
8073 );
8074 assert_eq!(recorded[0].0, "https://peer2.example.com");
8075 }
8076
8077 #[tokio::test]
8078 async fn test_broadcaster_not_called_when_no_outputs_admitted() {
8079 let broadcaster = MockBroadcaster::new();
8080 let (engine, calls) = make_engine_with_broadcaster(
8082 vec![],
8083 broadcaster,
8084 vec![("https://peer1.example.com", "tm_test")],
8085 None,
8086 )
8087 .await;
8088
8089 let beef = test_tagged_beef(vec!["tm_test"]);
8090 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8091
8092 assert!(steak["tm_test"].outputs_to_admit.is_empty());
8093
8094 assert_eq!(
8096 calls.lock().unwrap().len(),
8097 0,
8098 "Broadcaster should not be called when no outputs are admitted"
8099 );
8100 }
8101
8102 #[tokio::test]
8103 async fn test_broadcaster_not_called_when_no_ship_peers() {
8104 let broadcaster = MockBroadcaster::new();
8105 let (engine, calls) = make_engine_with_broadcaster(
8107 vec![0],
8108 broadcaster,
8109 vec![], None,
8111 )
8112 .await;
8113
8114 let beef = test_tagged_beef(vec!["tm_test"]);
8115 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8116
8117 assert_eq!(
8118 calls.lock().unwrap().len(),
8119 0,
8120 "Broadcaster should not be called when no SHIP peers exist"
8121 );
8122 }
8123
8124 struct SpendModeLookupService {
8130 mode: SpendNotificationMode,
8131 records: Mutex<Vec<UTXOReference>>,
8132 spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
8133 }
8134
8135 impl SpendModeLookupService {
8136 fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
8137 Self {
8138 mode,
8139 records: Mutex::new(Vec::new()),
8140 spent_payloads: capture,
8141 }
8142 }
8143 }
8144
8145 #[async_trait(?Send)]
8146 impl LookupServiceTrait for SpendModeLookupService {
8147 fn admission_mode(&self) -> AdmissionMode {
8148 AdmissionMode::LockingScript
8149 }
8150
8151 fn spend_notification_mode(&self) -> SpendNotificationMode {
8152 self.mode
8153 }
8154
8155 async fn output_admitted_by_topic(
8156 &self,
8157 payload: &OutputAdmittedByTopic,
8158 ) -> Result<(), LookupServiceError> {
8159 let (txid, oi) = match payload {
8160 OutputAdmittedByTopic::LockingScript {
8161 txid, output_index, ..
8162 } => (txid.clone(), *output_index),
8163 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
8164 ("whole".into(), *output_index)
8165 }
8166 };
8167 self.records.lock().unwrap().push(UTXOReference {
8168 txid,
8169 output_index: oi,
8170 });
8171 Ok(())
8172 }
8173
8174 async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
8175 self.spent_payloads.lock().unwrap().push(payload.clone());
8176 Ok(())
8177 }
8178
8179 async fn output_evicted(
8180 &self,
8181 txid: &str,
8182 output_index: u32,
8183 ) -> Result<(), LookupServiceError> {
8184 self.records
8185 .lock()
8186 .unwrap()
8187 .retain(|r| !(r.txid == txid && r.output_index == output_index));
8188 Ok(())
8189 }
8190
8191 async fn lookup(
8192 &self,
8193 _question: &LookupQuestion,
8194 ) -> Result<LookupResult, LookupServiceError> {
8195 Ok(LookupResult::OutputList(
8196 self.records.lock().unwrap().clone(),
8197 ))
8198 }
8199
8200 async fn get_documentation(&self) -> String {
8201 "SpendMode mock lookup service".to_string()
8202 }
8203
8204 async fn get_metadata(&self) -> ServiceMetadata {
8205 ServiceMetadata {
8206 name: "spend-mode-ls".to_string(),
8207 ..Default::default()
8208 }
8209 }
8210 }
8211
8212 const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
8214
8215 async fn make_spend_mode_engine(
8218 mode: SpendNotificationMode,
8219 ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
8220 let storage = MemoryStorage::new();
8221
8222 let prev_output = Output {
8224 txid: PREVIOUS_TXID.to_string(),
8225 output_index: 0,
8226 output_script: vec![0x76, 0xa9],
8227 satoshis: 26174,
8228 topic: "tm_test".to_string(),
8229 spent: false,
8230 outputs_consumed: vec![],
8231 consumed_by: vec![],
8232 beef: Some(test_beef()),
8233 block_height: None,
8234 score: Some(1000.0),
8235 };
8236 storage.insert_output(&prev_output).await.unwrap();
8237
8238 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8239 managers.insert(
8240 "tm_test".to_string(),
8241 Box::new(MockTopicManager::admitting(vec![0])),
8242 );
8243
8244 let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
8245 let ls = SpendModeLookupService::with_mode(mode, capture.clone());
8246 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8247 lookup_services.insert("ls_test".to_string(), Box::new(ls));
8248
8249 let engine = Engine::new(
8250 managers,
8251 lookup_services,
8252 Box::new(storage),
8253 None,
8254 EngineConfig::default(),
8255 );
8256
8257 (engine, capture)
8258 }
8259
8260 #[tokio::test]
8261 async fn test_spend_notification_mode_script() {
8262 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
8263
8264 let beef = test_tagged_beef(vec!["tm_test"]);
8265 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8266
8267 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8269
8270 let payloads = capture.lock().unwrap().clone();
8272 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8273
8274 match &payloads[0] {
8275 OutputSpent::Script {
8276 txid,
8277 output_index,
8278 topic,
8279 spending_txid,
8280 input_index,
8281 unlocking_script,
8282 sequence_number,
8283 ..
8284 } => {
8285 assert_eq!(txid, PREVIOUS_TXID);
8286 assert_eq!(*output_index, 0);
8287 assert_eq!(topic, "tm_test");
8288 assert_eq!(spending_txid, TEST_TXID);
8289 assert_eq!(*input_index, 0);
8291 assert!(
8293 !unlocking_script.is_empty(),
8294 "Unlocking script should be non-empty"
8295 );
8296 assert_eq!(*sequence_number, 0xffff_ffff);
8298 }
8299 other => panic!("Expected OutputSpent::Script, got: {other:?}"),
8300 }
8301 }
8302
8303 #[tokio::test]
8304 async fn test_spend_notification_mode_whole_tx() {
8305 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
8306
8307 let beef = test_tagged_beef(vec!["tm_test"]);
8308 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8309
8310 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8311
8312 let payloads = capture.lock().unwrap().clone();
8313 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8314
8315 match &payloads[0] {
8316 OutputSpent::WholeTx {
8317 txid,
8318 output_index,
8319 topic,
8320 spending_atomic_beef,
8321 ..
8322 } => {
8323 assert_eq!(txid, PREVIOUS_TXID);
8324 assert_eq!(*output_index, 0);
8325 assert_eq!(topic, "tm_test");
8326 let mut named =
8329 bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
8330 assert!(named.is_atomic());
8331 let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
8332 assert_eq!(
8333 named.txs.len(),
8334 submitted.txs.len(),
8335 "the whole body, never pruned"
8336 );
8337 let tip = crate::subject::subject_txid_of(&mut named).unwrap();
8338 assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
8339 }
8340 other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
8341 }
8342 }
8343
8344 #[tokio::test]
8345 async fn test_spend_notification_mode_txid() {
8346 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
8347
8348 let beef = test_tagged_beef(vec!["tm_test"]);
8349 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8350
8351 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8352
8353 let payloads = capture.lock().unwrap().clone();
8354 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8355
8356 match &payloads[0] {
8357 OutputSpent::Txid {
8358 txid,
8359 output_index,
8360 topic,
8361 spending_txid,
8362 } => {
8363 assert_eq!(txid, PREVIOUS_TXID);
8364 assert_eq!(*output_index, 0);
8365 assert_eq!(topic, "tm_test");
8366 assert_eq!(spending_txid, TEST_TXID);
8367 }
8368 other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
8369 }
8370 }
8371
8372 #[tokio::test]
8373 async fn test_spend_notification_mode_none() {
8374 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
8375
8376 let beef = test_tagged_beef(vec!["tm_test"]);
8377 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8378
8379 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8380
8381 let payloads = capture.lock().unwrap().clone();
8382 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8383
8384 match &payloads[0] {
8385 OutputSpent::None {
8386 txid,
8387 output_index,
8388 topic,
8389 } => {
8390 assert_eq!(txid, PREVIOUS_TXID);
8391 assert_eq!(*output_index, 0);
8392 assert_eq!(topic, "tm_test");
8393 }
8394 other => panic!("Expected OutputSpent::None, got: {other:?}"),
8395 }
8396 }
8397
8398 #[tokio::test]
8401 async fn test_start_gasp_sync_empty_config() {
8402 let engine = Engine::new(
8403 HashMap::new(),
8404 HashMap::new(),
8405 Box::new(MemoryStorage::new()),
8406 None,
8407 EngineConfig {
8408 sync_configuration: HashMap::new(),
8409 ..Default::default()
8410 },
8411 );
8412
8413 let result = engine.start_gasp_sync().await.unwrap();
8414 assert!(
8415 result.topics_synced.is_empty(),
8416 "No topics should be synced with empty config"
8417 );
8418 }
8419
8420 #[tokio::test]
8421 async fn test_start_gasp_sync_disabled_topic_skipped() {
8422 let mut sync_config: SyncConfiguration = HashMap::new();
8423 sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
8424
8425 let engine = Engine::new(
8426 HashMap::new(),
8427 HashMap::new(),
8428 Box::new(MemoryStorage::new()),
8429 None,
8430 EngineConfig {
8431 sync_configuration: sync_config,
8432 ..Default::default()
8433 },
8434 );
8435
8436 let result = engine.start_gasp_sync().await.unwrap();
8437 assert!(
8438 result.topics_synced.is_empty(),
8439 "Disabled topics should not appear in results"
8440 );
8441 }
8442
8443 #[tokio::test]
8444 async fn test_start_gasp_sync_peers_config() {
8445 let mut sync_config: SyncConfiguration = HashMap::new();
8446 sync_config.insert(
8447 "tm_test".to_string(),
8448 SyncTarget::Peers(vec![
8449 "https://peer1.example.com".to_string(),
8450 "https://peer2.example.com".to_string(),
8451 ]),
8452 );
8453
8454 let engine = Engine::new(
8455 HashMap::new(),
8456 HashMap::new(),
8457 Box::new(MemoryStorage::new()),
8458 None,
8459 EngineConfig {
8460 sync_configuration: sync_config,
8461 ..Default::default()
8462 },
8463 );
8464
8465 let result = engine.start_gasp_sync().await.unwrap();
8466 assert_eq!(result.topics_synced.len(), 1);
8467
8468 let topic_result = &result.topics_synced["tm_test"];
8469 assert_eq!(topic_result.peers.len(), 2);
8470 assert_eq!(topic_result.sync_type, "peers");
8471 assert!(topic_result.errors.is_empty());
8472 }
8473
8474 #[tokio::test]
8475 async fn test_start_gasp_sync_peers_filters_self() {
8476 let mut sync_config: SyncConfiguration = HashMap::new();
8477 sync_config.insert(
8478 "tm_test".to_string(),
8479 SyncTarget::Peers(vec![
8480 "https://self.example.com".to_string(),
8481 "https://peer2.example.com".to_string(),
8482 ]),
8483 );
8484
8485 let engine = Engine::new(
8486 HashMap::new(),
8487 HashMap::new(),
8488 Box::new(MemoryStorage::new()),
8489 None,
8490 EngineConfig {
8491 hosting_url: Some("https://self.example.com".to_string()),
8492 sync_configuration: sync_config,
8493 ..Default::default()
8494 },
8495 );
8496
8497 let result = engine.start_gasp_sync().await.unwrap();
8498 let topic_result = &result.topics_synced["tm_test"];
8499 assert_eq!(
8500 topic_result.peers.len(),
8501 1,
8502 "Self URL should be filtered out"
8503 );
8504 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
8505 }
8506
8507 #[tokio::test]
8508 async fn load_0112_quiet_peer_costs_no_request_during_backoff() {
8509 let calls = std::rc::Rc::default();
8510 let store = std::rc::Rc::new(MemoryStorage::new());
8511 let mut engine = Engine::new(
8512 HashMap::new(),
8513 HashMap::new(),
8514 Box::new(store.clone()),
8515 None,
8516 EngineConfig {
8517 sync_configuration: HashMap::from([(
8518 "tm_test".into(),
8519 SyncTarget::Peers(vec!["https://quiet.example".into()]),
8520 )]),
8521 ..Default::default()
8522 },
8523 );
8524 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8525 calls: std::rc::Rc::clone(&calls),
8526 }));
8527 for _ in 0..4 {
8528 engine.start_gasp_sync().await.unwrap();
8529 }
8530 assert_eq!(calls.borrow().len(), 3);
8531 assert_eq!(
8532 store
8533 .get_peer_sync_health("quiet.example", "tm_test")
8534 .await
8535 .unwrap()
8536 .consecutive_failures,
8537 0
8538 );
8539 store.advance_clock(60);
8540 engine.start_gasp_sync().await.unwrap();
8541 assert_eq!(calls.borrow().len(), 4);
8542 engine.start_gasp_sync().await.unwrap();
8543 assert_eq!(calls.borrow().len(), 4);
8544 engine
8545 .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8546 topics: Some(vec!["tm_test".into()]),
8547 peers: None,
8548 })
8549 .await
8550 .unwrap();
8551 assert_eq!(calls.borrow().len(), 5);
8552 engine.start_gasp_sync().await.unwrap();
8553 assert_eq!(
8554 calls.borrow().len(),
8555 6,
8556 "explicit selection reset the quiet streak"
8557 );
8558 }
8559
8560 #[tokio::test]
8561 async fn load_0112_yield_and_advertisement_reset_cadence() {
8562 let (mut engine, remote) = budget_page_engine(1);
8563 engine.set_gasp_remote_factory(Box::new(remote));
8564 engine
8565 .storage
8566 .set_peer_cadence(
8567 "budget.example",
8568 "tm_test",
8569 &crate::sync_load::PeerCadence {
8570 quiet_syncs: 8,
8571 retry_after_secs: 0,
8572 advertisement: String::new(),
8573 },
8574 )
8575 .await
8576 .unwrap();
8577 let result = engine.start_gasp_sync().await.unwrap();
8578 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
8579 assert_eq!(
8580 engine
8581 .storage
8582 .get_peer_cadence("budget.example", "tm_test")
8583 .await
8584 .unwrap()
8585 .quiet_syncs,
8586 0
8587 );
8588 engine
8589 .storage
8590 .set_peer_cadence(
8591 "budget.example",
8592 "tm_test",
8593 &crate::sync_load::PeerCadence {
8594 quiet_syncs: 8,
8595 retry_after_secs: 3_600,
8596 advertisement: "previous-advertisement".into(),
8597 },
8598 )
8599 .await
8600 .unwrap();
8601 let result = engine.start_gasp_sync().await.unwrap();
8602 assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8603 assert!(
8604 engine
8605 .storage
8606 .get_peer_cadence("budget.example", "tm_test")
8607 .await
8608 .unwrap()
8609 .quiet_syncs
8610 <= 1
8611 );
8612 }
8613
8614 #[tokio::test]
8615 async fn load_0112_an_admitted_slap_change_wakes_its_peer() {
8616 let (mut engine, mut remote) = budget_page_engine(0);
8617 remote.node_cost_ms = 0;
8618 engine.set_gasp_remote_factory(Box::new(remote.clone()));
8619 for _ in 0..3 {
8620 engine.start_gasp_sync().await.unwrap();
8621 }
8622 let skipped = engine.start_gasp_sync().await.unwrap();
8623 assert_eq!(
8624 skipped.topics_synced["tm_test"].peers_skipped[0].reason,
8625 "no_yield"
8626 );
8627 let count = remote.requests.borrow().len();
8628 let ad = discovery_output("SLAP", "https://budget.example", "ls_ship");
8629 let tx = beef_limits::transaction_from_beef(&ad.beef, None, &beef_limits::PEER_BEEF_LIMITS)
8630 .unwrap();
8631 engine
8632 .storage
8633 .insert_output(&Output {
8634 txid: tx.id(),
8635 output_index: 0,
8636 output_script: tx.outputs[0].locking_script.to_binary(),
8637 satoshis: 1,
8638 topic: "tm_slap".into(),
8639 spent: false,
8640 outputs_consumed: vec![],
8641 consumed_by: vec![],
8642 beef: None,
8643 block_height: None,
8644 score: Some(1.0),
8645 })
8646 .await
8647 .unwrap();
8648 let result = engine.start_gasp_sync().await.unwrap();
8649 assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8650 assert_eq!(remote.requests.borrow().len(), count + 1);
8651 }
8652
8653 #[tokio::test]
8654 async fn load_0112_filter_selects_only_named_topic_and_peer() {
8655 let calls = std::rc::Rc::default();
8656 let mut engine = Engine::new(
8657 HashMap::new(),
8658 HashMap::new(),
8659 Box::new(MemoryStorage::new()),
8660 None,
8661 EngineConfig {
8662 sync_configuration: ["tm_alpha", "tm_beta"]
8663 .into_iter()
8664 .map(|t| {
8665 (
8666 t.to_string(),
8667 SyncTarget::Peers(vec![
8668 "https://a.example".into(),
8669 "https://b.example".into(),
8670 ]),
8671 )
8672 })
8673 .collect(),
8674 ..Default::default()
8675 },
8676 );
8677 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8678 calls: std::rc::Rc::clone(&calls),
8679 }));
8680 let filter = crate::sync_load::SyncFilter::parse(
8681 r#"{"topics":["tm_beta"],"peers":["https://b.example"]}"#,
8682 )
8683 .unwrap();
8684 let result = engine.start_gasp_sync_filtered(&filter).await.unwrap();
8685 assert_eq!(
8686 *calls.borrow(),
8687 vec![("https://b.example".into(), "tm_beta".into())]
8688 );
8689 assert_eq!(result.topics_synced.len(), 1);
8690 calls.borrow_mut().clear();
8691 engine.start_gasp_sync().await.unwrap();
8692 assert_eq!(calls.borrow().len(), 4);
8693 calls.borrow_mut().clear();
8694 engine
8695 .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8696 topics: Some(vec![]),
8697 peers: None,
8698 })
8699 .await
8700 .unwrap();
8701 assert!(calls.borrow().is_empty());
8702 assert!(crate::sync_load::SyncFilter::parse(r#"{"peers":"bad"}"#).is_err());
8703 assert!(crate::sync_load::SyncFilter::parse("null").is_err());
8704 assert_eq!(
8705 crate::sync_load::SyncFilter::parse(" ").unwrap(),
8706 crate::sync_load::SyncFilter::default()
8707 );
8708 }
8709
8710 #[derive(Clone, Default)]
8711 struct MockShipLookupClient {
8712 records: HashMap<(String, String), Vec<OutputListItem>>,
8713 failures: HashSet<String>,
8714 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
8715 }
8716
8717 struct RecordingDiscoverySyncFactory {
8718 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
8719 }
8720
8721 impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
8722 fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8723 self.calls
8724 .borrow_mut()
8725 .push((peer.to_string(), topic.to_string()));
8726 Box::new(MockSyncRemote { utxos: Vec::new() })
8727 }
8728 }
8729
8730 #[async_trait(?Send)]
8731 impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
8732 async fn lookup(
8733 &self,
8734 host: &str,
8735 question: &LookupQuestion,
8736 ) -> Result<Vec<OutputListItem>, String> {
8737 self.calls
8738 .borrow_mut()
8739 .push((host.to_string(), question.clone()));
8740 if self.failures.contains(host) {
8741 return Err("mock tracker unavailable".to_string());
8742 }
8743 Ok(self
8744 .records
8745 .get(&(host.to_string(), question.service.clone()))
8746 .cloned()
8747 .unwrap_or_default())
8748 }
8749 }
8750
8751 fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
8752 use bsv_rs::script::templates::PushDrop;
8753 let pubkey = bsv_rs::PublicKey::from_hex(
8754 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
8755 )
8756 .unwrap();
8757 let script = PushDrop::new(
8758 pubkey,
8759 vec![
8760 protocol.as_bytes().to_vec(),
8761 vec![0x02; 33],
8762 domain.as_bytes().to_vec(),
8763 scope.as_bytes().to_vec(),
8764 ],
8765 )
8766 .lock();
8767 let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
8768 tx.outputs[0].locking_script = script;
8769 tx.merkle_path = None;
8770 tx.invalidate_caches();
8771 let beef = tx.to_atomic_beef(true).unwrap();
8772 let parsed =
8773 beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
8774 .unwrap();
8775 assert_eq!(
8776 parsed.outputs[0].locking_script,
8777 tx.outputs[0].locking_script
8778 );
8779 OutputListItem {
8780 beef,
8781 output_index: 0,
8782 context: None,
8783 }
8784 }
8785
8786 #[tokio::test]
8787 async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
8788 let (mut engine, _) = make_engine_with_broadcaster(
8789 vec![0],
8790 MockBroadcaster::new(),
8791 vec![
8792 ("https://local.example", "tm_test"),
8793 ("https://shared.example/?local", "tm_test"),
8794 ],
8795 Some("https://self.example"),
8796 )
8797 .await;
8798 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8799 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8800 let mut client = MockShipLookupClient::default();
8801 client.records.insert(
8802 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8803 vec![
8804 discovery_output("SHIP", "https://direct.example", "tm_test"),
8805 discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
8806 discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
8807 discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
8808 discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
8809 OutputListItem {
8810 beef: vec![1, 2, 3],
8811 output_index: 0,
8812 context: None,
8813 },
8814 ],
8815 );
8816 client.records.insert(
8817 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8818 vec![
8819 discovery_output("SLAP", "https://lookup.example", "ls_ship"),
8820 discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
8821 discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
8822 discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
8823 discovery_output("SLAP", "https://other-service.example", "ls_other"),
8824 ],
8825 );
8826 client.records.insert(
8827 ("https://lookup.example".to_string(), "ls_ship".to_string()),
8828 vec![
8829 discovery_output("SHIP", "https://indirect.example", "tm_test"),
8830 discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
8831 ],
8832 );
8833 let calls = client.calls.clone();
8834 engine.set_ship_lookup_client(Box::new(client));
8835 let result = engine.start_gasp_sync().await.unwrap();
8836 assert_eq!(
8837 result.topics_synced["tm_test"].peers,
8838 vec![
8839 "https://bootstrap.example",
8840 "https://direct.example",
8841 "https://indirect.example",
8842 "https://local.example",
8843 "https://shared.example",
8844 ]
8845 );
8846 let calls = calls.borrow();
8847 assert_eq!(calls.len(), 3);
8848 for (_, question) in calls.iter() {
8849 let expected = if question.service == "ls_slap" {
8850 serde_json::json!({"service": "ls_ship"})
8851 } else {
8852 serde_json::json!({"topics": ["tm_test"]})
8853 };
8854 assert_eq!(question.query, expected);
8855 }
8856 }
8857
8858 #[tokio::test]
8859 async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
8860 let (mut engine, _) =
8861 make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
8862 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8863 let mut client = MockShipLookupClient::default();
8864 let outputs = (0..9)
8865 .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
8866 .collect();
8867 client.records.insert(
8868 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8869 outputs,
8870 );
8871 engine.set_ship_lookup_client(Box::new(client));
8872 let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8873 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8874 calls: sync_calls.clone(),
8875 }));
8876 let result = engine.start_gasp_sync().await.unwrap();
8877 let topic = &result.topics_synced["tm_test"];
8878 assert_eq!(topic.sync_type, "ship");
8879 assert_eq!(
8880 topic.peers.len(),
8881 9,
8882 "the lookup-host cap must not truncate sync peers"
8883 );
8884 assert!(topic.errors.is_empty());
8885 assert_eq!(sync_calls.borrow().len(), 9);
8886 for peer in &topic.peers {
8887 assert!(sync_calls
8888 .borrow()
8889 .contains(&(peer.clone(), "tm_test".to_string())));
8890 }
8891 }
8892
8893 #[tokio::test]
8894 async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
8895 let mut engine = make_engine(vec![]);
8896 engine.config.slap_trackers = vec![
8897 "https://dead.example".to_string(),
8898 "https://live.example".to_string(),
8899 ];
8900 let mut client = MockShipLookupClient::default();
8901 client.failures.insert("https://dead.example".to_string());
8902 client.records.insert(
8903 ("https://live.example".to_string(), "ls_ship".to_string()),
8904 vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
8905 );
8906 engine.set_ship_lookup_client(Box::new(client));
8907 let result = engine.start_gasp_sync().await.unwrap();
8908 assert_eq!(
8909 result.topics_synced["tm_test"].peers,
8910 vec!["https://peer.example"]
8911 );
8912 }
8913
8914 #[tokio::test]
8915 async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
8916 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
8917 let mut engine = make_engine(vec![]);
8918 engine
8919 .config
8920 .sync_configuration
8921 .insert("tm_other".to_string(), SyncTarget::Ship);
8922 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8923 let mut client = MockShipLookupClient::default();
8924 let mut providers = Vec::new();
8925 for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
8926 let host = format!("https://lookup{n}.example");
8927 providers.push(discovery_output("SLAP", &host, "ls_ship"));
8928 client.records.insert(
8929 (host, "ls_ship".to_string()),
8930 vec![
8931 discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
8932 discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
8933 ],
8934 );
8935 }
8936 client.records.insert(
8937 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8938 providers,
8939 );
8940 let calls = client.calls.clone();
8941 engine.set_ship_lookup_client(Box::new(client));
8942 let result = engine.start_gasp_sync().await.unwrap();
8943 assert_eq!(
8944 result.topics_synced["tm_test"].peers.len(),
8945 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8946 );
8947 assert_eq!(
8948 result.topics_synced["tm_other"].peers.len(),
8949 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8950 );
8951 let calls = calls.borrow();
8952 assert_eq!(
8953 calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
8954 1
8955 );
8956 let providers: HashSet<_> = calls
8957 .iter()
8958 .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
8959 .map(|(h, _)| h)
8960 .collect();
8961 assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
8962 assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
8963 }
8964
8965 #[tokio::test]
8966 async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
8967 let mut engine = make_engine(vec![]);
8968 engine.config.hosting_url = Some("https://self.example".to_string());
8969 engine.config.slap_trackers = vec!["https://dead.example".to_string()];
8970 let mut client = MockShipLookupClient::default();
8971 client.failures.insert("https://dead.example".to_string());
8972 engine.set_ship_lookup_client(Box::new(client));
8973 engine.set_ship_bootstrap_peers(
8974 "tm_test",
8975 vec![
8976 "https://bootstrap.example".to_string(),
8977 "https://BOOTSTRAP.example.:443/?x".to_string(),
8978 "http://SELF.example.:8443/path".to_string(),
8979 ],
8980 );
8981 let result = engine.start_gasp_sync().await.unwrap();
8982 assert_eq!(
8983 result.topics_synced["tm_test"].peers,
8984 vec!["https://bootstrap.example"]
8985 );
8986 }
8987
8988 #[tokio::test]
8989 async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
8990 let mut engine = make_engine(vec![]);
8991 for topic in ["tm_ship", "tm_slap"] {
8992 engine.config.sync_configuration.insert(
8993 topic.to_string(),
8994 SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
8995 );
8996 }
8997 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8998 let mut client = MockShipLookupClient::default();
8999 client.records.insert(
9000 ("https://tracker.example".to_string(), "ls_ship".to_string()),
9001 vec![discovery_output(
9002 "SHIP",
9003 "https://discovered.example",
9004 "tm_test",
9005 )],
9006 );
9007 let lookups = client.calls.clone();
9008 engine.set_ship_lookup_client(Box::new(client));
9009 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
9010 let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
9011 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
9012 calls: calls.clone(),
9013 }));
9014 engine.start_gasp_sync().await.unwrap();
9015 assert_eq!(
9016 calls
9017 .borrow()
9018 .iter()
9019 .map(|(_, topic)| topic.as_str())
9020 .collect::<Vec<_>>(),
9021 vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
9022 );
9023 assert_eq!(lookups.borrow().len(), 2);
9024 }
9025
9026 #[tokio::test]
9027 async fn test_start_gasp_sync_ship_discovers_peers() {
9028 let broadcaster = MockBroadcaster::new();
9029 let (engine, _calls) = make_engine_with_broadcaster(
9030 vec![0],
9031 broadcaster,
9032 vec![
9033 ("https://peer1.example.com", "tm_test"),
9034 ("https://peer2.example.com", "tm_test"),
9035 ],
9036 Some("https://self.example.com"),
9037 )
9038 .await;
9039
9040 let result = engine.start_gasp_sync().await.unwrap();
9041 assert!(
9042 result.topics_synced.contains_key("tm_test"),
9043 "tm_test should be in sync results"
9044 );
9045
9046 let topic_result = &result.topics_synced["tm_test"];
9047 assert_eq!(topic_result.sync_type, "ship");
9048 assert_eq!(
9049 topic_result.peers.len(),
9050 2,
9051 "Should discover both SHIP peers"
9052 );
9053 assert!(topic_result
9054 .peers
9055 .contains(&"https://peer1.example.com".to_string()));
9056 assert!(topic_result
9057 .peers
9058 .contains(&"https://peer2.example.com".to_string()));
9059 }
9060
9061 #[tokio::test]
9062 async fn test_start_gasp_sync_ship_filters_self() {
9063 let broadcaster = MockBroadcaster::new();
9064 let (engine, _calls) = make_engine_with_broadcaster(
9065 vec![0],
9066 broadcaster,
9067 vec![
9068 ("https://self.example.com", "tm_test"),
9069 ("https://peer2.example.com", "tm_test"),
9070 ],
9071 Some("https://self.example.com"),
9072 )
9073 .await;
9074
9075 let result = engine.start_gasp_sync().await.unwrap();
9076 let topic_result = &result.topics_synced["tm_test"];
9077 assert_eq!(
9078 topic_result.peers.len(),
9079 1,
9080 "Self URL should be filtered out from SHIP discovery"
9081 );
9082 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
9083 }
9084
9085 #[tokio::test]
9086 async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
9087 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9088 managers.insert(
9089 "tm_custom".to_string(),
9090 Box::new(MockTopicManager::admitting(vec![])),
9091 );
9092
9093 let mut sync_config: SyncConfiguration = HashMap::new();
9094 sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
9095
9096 let engine = Engine::new(
9097 managers,
9098 HashMap::new(),
9099 Box::new(MemoryStorage::new()),
9100 None,
9101 EngineConfig {
9102 sync_configuration: sync_config,
9103 ..Default::default()
9104 },
9105 );
9106
9107 let result = engine.start_gasp_sync().await.unwrap();
9108 let topic_result = &result.topics_synced["tm_custom"];
9109 assert!(
9110 topic_result.peers.is_empty(),
9111 "Without ls_ship, SHIP discovery should return no peers"
9112 );
9113 }
9114
9115 #[tokio::test]
9116 async fn test_start_gasp_sync_mixed_config() {
9117 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9118 managers.insert(
9119 "tm_peers".to_string(),
9120 Box::new(MockTopicManager::admitting(vec![])),
9121 );
9122 managers.insert(
9123 "tm_disabled".to_string(),
9124 Box::new(MockTopicManager::admitting(vec![])),
9125 );
9126 managers.insert(
9127 "tm_ship_topic".to_string(),
9128 Box::new(MockTopicManager::admitting(vec![])),
9129 );
9130
9131 let mut sync_config: SyncConfiguration = HashMap::new();
9132 sync_config.insert(
9133 "tm_peers".to_string(),
9134 SyncTarget::Peers(vec!["https://peer.com".to_string()]),
9135 );
9136 sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
9137 sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
9138
9139 let engine = Engine::new(
9140 managers,
9141 HashMap::new(),
9142 Box::new(MemoryStorage::new()),
9143 None,
9144 EngineConfig {
9145 sync_configuration: sync_config,
9146 ..Default::default()
9147 },
9148 );
9149
9150 let result = engine.start_gasp_sync().await.unwrap();
9151 assert_eq!(result.topics_synced.len(), 2);
9152 assert!(result.topics_synced.contains_key("tm_peers"));
9153 assert!(result.topics_synced.contains_key("tm_ship_topic"));
9154 assert!(!result.topics_synced.contains_key("tm_disabled"));
9155
9156 assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
9157 assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
9158 assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
9159 }
9160
9161 #[tokio::test]
9162 async fn test_gasp_sync_result_serialization() {
9163 let mut topics_synced = HashMap::new();
9164 topics_synced.insert(
9165 "tm_test".to_string(),
9166 super::TopicSyncResult {
9167 cost: crate::meter::SyncCost::default(),
9168 root_bounds: Vec::new(),
9169 peers_skipped: Vec::new(),
9170 peers_filtered: Vec::new(),
9171 roots_proven: 0,
9172 roots_unproven: 0,
9173 cursor_held_by: Vec::new(),
9174 expired_refused: 0,
9175 peers: vec!["https://peer.com".to_string()],
9176 sync_type: "ship".to_string(),
9177 errors: vec![],
9178 pruned_inputs: 0,
9179 discarded_graphs: 0,
9180 anchor_walk_held_graphs: 0,
9181 finalized_graphs: 0,
9182 deadline_dropped_graphs: 0,
9183 cursor_moves: Vec::new(),
9184 deferred_graphs: 0,
9185 resumed_graphs: 0,
9186 converged_graphs: 0,
9187 dropped_graphs: Vec::new(),
9188 stalled_graphs: 0,
9189 held_back_graphs: 0,
9190 backed_off_graphs: 0,
9191 waiting_graphs: 0,
9192 idle_counted: 0,
9193 peer_results: HashMap::new(),
9194 dead_graphs: 0,
9195 expired_swept: 0,
9196 memo_folded: 0,
9197 unanchorable_graphs: 0,
9198 unanchorable_cleared: 0,
9199 absent_struck: 0,
9200 route_faults: Vec::new(),
9201 strikes_refused: 0,
9202 faulted_roots: Vec::new(),
9203 },
9204 );
9205
9206 let result = super::GASPSyncResult {
9207 cost: crate::meter::SyncCost::default(),
9208 topics_synced,
9209 filter: crate::sync_load::SyncFilter::default(),
9210 topics_filtered: Vec::new(),
9211 };
9212 let json = serde_json::to_string(&result).unwrap();
9213 let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
9214
9215 assert_eq!(back.topics_synced.len(), 1);
9216 assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
9217 assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
9218 }
9219
9220 struct MockSyncRemote {
9224 utxos: Vec<crate::types::GASPOutput>,
9225 }
9226
9227 #[async_trait(?Send)]
9228 impl crate::gasp::GASPRemote for MockSyncRemote {
9229 async fn get_initial_response(
9230 &self,
9231 request: &crate::types::GASPInitialRequest,
9232 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9233 let utxos: Vec<crate::types::GASPOutput> = self
9234 .utxos
9235 .iter()
9236 .filter(|u| u.score as u64 >= request.since)
9237 .cloned()
9238 .collect();
9239 Ok(crate::types::GASPInitialResponse {
9240 utxo_list: utxos,
9241 since: request.since,
9242 })
9243 }
9244 async fn get_initial_reply(
9245 &self,
9246 _: &crate::types::GASPInitialResponse,
9247 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9248 Ok(crate::types::GASPInitialReply {
9249 utxo_list: Vec::new(),
9250 })
9251 }
9252 async fn request_node(
9253 &self,
9254 graph_id: &str,
9255 txid: &str,
9256 output_index: u32,
9257 _: bool,
9258 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9259 Ok(crate::types::GASPNode {
9260 graph_id: graph_id.to_string(),
9261 raw_tx: format!("rawtx_{txid}"),
9262 output_index,
9263 proof: None,
9264 tx_metadata: None,
9265 output_metadata: None,
9266 inputs: None,
9267 })
9268 }
9269 async fn submit_node(
9270 &self,
9271 _: &crate::types::GASPNode,
9272 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9273 Ok(None)
9274 }
9275 }
9276
9277 struct MockGASPRemoteFactory;
9279
9280 #[derive(Clone)]
9281 struct BudgetPageRemote {
9282 outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
9283 nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
9284 elapsed: std::rc::Rc<Cell<u64>>,
9285 requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
9286 clamp: Option<usize>,
9287 node_cost_ms: u64,
9288 slow_once: std::rc::Rc<std::cell::RefCell<Option<(String, u64)>>>,
9290 asked: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
9292 faults_for: std::rc::Rc<std::cell::RefCell<std::collections::HashSet<String>>>,
9294 }
9295
9296 #[async_trait(?Send)]
9297 impl crate::gasp::GASPRemote for BudgetPageRemote {
9298 async fn get_initial_response(
9299 &self,
9300 request: &crate::types::GASPInitialRequest,
9301 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9302 self.requests.borrow_mut().push(request.clone());
9303 let page = self
9304 .outputs
9305 .iter()
9306 .filter(|u| u.score as u64 >= request.since);
9307 let limit = self.clamp.map_or(usize::MAX, |cap| {
9308 cap.min(request.limit.unwrap_or(u64::MAX) as usize)
9309 });
9310 Ok(crate::types::GASPInitialResponse {
9311 utxo_list: page.take(limit).cloned().collect(),
9312 since: request.since,
9313 })
9314 }
9315
9316 async fn get_initial_reply(
9317 &self,
9318 _: &crate::types::GASPInitialResponse,
9319 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9320 unreachable!()
9321 }
9322
9323 async fn request_node(
9324 &self,
9325 graph_id: &str,
9326 txid: &str,
9327 _: u32,
9328 _: bool,
9329 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9330 self.asked.borrow_mut().push(txid.to_string());
9331 self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
9332 let slow = {
9333 let mut slow = self.slow_once.borrow_mut();
9334 match slow.as_ref() {
9335 Some((t, _)) if t == txid => slow.take().map(|(_, ms)| ms),
9336 _ => None,
9337 }
9338 };
9339 if let Some(ms) = slow {
9340 self.elapsed.set(self.elapsed.get() + ms);
9341 }
9342 if self.faults_for.borrow().contains(txid) {
9343 return Err(crate::gasp::GASPError::RemoteError("HTTP 503".into()));
9344 }
9345 if self.node_cost_ms > 0 {
9346 let mut yielded = false;
9347 std::future::poll_fn(|cx| {
9348 if yielded {
9349 std::task::Poll::Ready(())
9350 } else {
9351 yielded = true;
9352 cx.waker().wake_by_ref();
9353 std::task::Poll::Pending
9354 }
9355 })
9356 .await;
9357 }
9358 let mut node = self
9359 .nodes
9360 .get(txid)
9361 .cloned()
9362 .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
9363 node.graph_id = graph_id.to_string();
9364 Ok(node)
9365 }
9366
9367 async fn submit_node(
9368 &self,
9369 _: &crate::types::GASPNode,
9370 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9371 unreachable!()
9372 }
9373 }
9374
9375 impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
9376 fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
9377 Box::new(self.clone())
9378 }
9379 }
9380
9381 fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
9382 let mut engine = make_engine(vec![0]);
9383 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
9384 let mut outputs = Vec::new();
9385 let mut nodes = HashMap::new();
9386 for n in 1..=count {
9387 let tx = child_of(&format!("{n:064x}"), 0);
9388 let txid = tx.id();
9389 tracker.add_root(n, txid.clone());
9390 outputs.push(crate::types::GASPOutput {
9391 txid: txid.clone(),
9392 output_index: 0,
9393 score: f64::from(n * 10),
9394 });
9395 nodes.insert(
9396 txid.clone(),
9397 crate::types::GASPNode {
9398 graph_id: String::new(),
9399 raw_tx: tx.to_hex(),
9400 output_index: 0,
9401 proof: Some(single_leaf_bump_hex(&txid, n)),
9402 tx_metadata: None,
9403 output_metadata: None,
9404 inputs: None,
9405 },
9406 );
9407 }
9408 engine.chain_tracker = Some(Box::new(tracker));
9409 engine.config.sync_configuration.insert(
9410 "tm_test".to_string(),
9411 SyncTarget::Peers(vec!["https://budget.example".to_string()]),
9412 );
9413 let remote = BudgetPageRemote {
9414 outputs: std::rc::Rc::new(outputs),
9415 nodes: std::rc::Rc::new(nodes),
9416 elapsed: std::rc::Rc::new(Cell::new(0)),
9417 requests: std::rc::Rc::default(),
9418 clamp: None,
9419 node_cost_ms: 5000,
9420 slow_once: std::rc::Rc::default(),
9421 asked: std::rc::Rc::default(),
9422 faults_for: std::rc::Rc::default(),
9423 };
9424 (engine, remote)
9425 }
9426
9427 fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
9428 let elapsed = remote.elapsed.clone();
9429 engine.set_peer_sync_budget(
9430 std::rc::Rc::new(move |ms| {
9431 assert_eq!(ms, 30_000);
9432 let elapsed = elapsed.clone();
9433 let until = elapsed.get() + ms;
9434 Box::pin(std::future::poll_fn(move |_| {
9435 if elapsed.get() >= until {
9436 std::task::Poll::Ready(())
9437 } else {
9438 std::task::Poll::Pending
9439 }
9440 }))
9441 }),
9442 30_000,
9443 );
9444 }
9445
9446 #[tokio::test]
9447 async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
9448 let (mut engine, remote) = budget_page_engine(3000);
9451 virtual_peer_budget(&mut engine, &remote);
9452 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9453 for round in 1..=2 {
9454 let result = engine.start_gasp_sync().await.unwrap();
9455 let topic = &result.topics_synced["tm_test"];
9456 assert_eq!(topic.finalized_graphs, 5);
9457 assert_eq!(topic.deadline_dropped_graphs, 1);
9458 assert_eq!(topic.cursor_moves.len(), 1);
9459 let expected = round * 50;
9460 assert_eq!(topic.cursor_moves[0].to, expected);
9461 assert_eq!(
9462 engine
9463 .storage
9464 .get_last_interaction("https://budget.example", "tm_test")
9465 .await
9466 .unwrap(),
9467 expected
9468 );
9469 {
9470 let requests = remote.requests.borrow();
9471 assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
9472 }
9473 for output in remote.outputs.iter().take((round * 5) as usize) {
9474 assert!(engine
9475 .storage
9476 .find_output(&output.txid, 0, Some("tm_test"), None, true)
9477 .await
9478 .unwrap()
9479 .is_some());
9480 }
9481 let unfinished = &remote.outputs[(round * 5) as usize];
9482 assert!(engine
9483 .storage
9484 .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
9485 .await
9486 .unwrap()
9487 .is_none());
9488 }
9489 }
9490
9491 #[tokio::test]
9492 async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
9493 let (mut engine, mut remote) = budget_page_engine(7);
9494 remote.clamp = Some(3);
9495 remote.node_cost_ms = 0;
9496 virtual_peer_budget(&mut engine, &remote);
9497 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9498 let result = engine.start_gasp_sync().await.unwrap();
9499 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
9500 let requests = remote.requests.borrow();
9501 assert_eq!(requests.len(), 4);
9502 assert!(requests.iter().all(|request| request.limit == Some(100)));
9503 assert_eq!(
9504 requests.iter().map(|r| r.since).collect::<Vec<_>>(),
9505 vec![0, 30, 50, 70]
9506 );
9507 }
9508
9509 #[tokio::test]
9510 async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
9511 let (mut engine, mut remote) = budget_page_engine(3000);
9512 std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
9513 virtual_peer_budget(&mut engine, &remote);
9514 engine.set_gasp_remote_factory(Box::new(remote));
9515 let result = engine.start_gasp_sync().await.unwrap();
9516 let topic = &result.topics_synced["tm_test"];
9517 assert_eq!(topic.finalized_graphs, 5);
9518 assert!(topic.cursor_moves.is_empty());
9521 }
9522
9523 #[tokio::test]
9524 async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
9525 let (mut engine, mut remote) = budget_page_engine(3);
9526 std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
9527 remote.node_cost_ms = 10_000;
9528 virtual_peer_budget(&mut engine, &remote);
9529 engine.set_gasp_remote_factory(Box::new(remote));
9530 let result = engine.start_gasp_sync().await.unwrap();
9531 let topic = &result.topics_synced["tm_test"];
9532 assert_eq!(topic.finalized_graphs, 2);
9533 assert_eq!(topic.cursor_moves[0].to, 10);
9534 }
9535
9536 #[tokio::test]
9537 async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
9538 let (mut engine, mut remote) = budget_page_engine(3000);
9539 let failed = remote.outputs[2].txid.clone();
9540 std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
9541 virtual_peer_budget(&mut engine, &remote);
9542 engine.set_gasp_remote_factory(Box::new(remote));
9543 let result = engine.start_gasp_sync().await.unwrap();
9544 let topic = &result.topics_synced["tm_test"];
9545 assert_eq!(topic.finalized_graphs, 4);
9546 assert_eq!(topic.cursor_moves[0].to, 20);
9547 }
9548
9549 struct ProofOnlyRootFetcher {
9550 proofs: HashMap<String, String>,
9551 requested: std::rc::Rc<Cell<u64>>,
9552 }
9553
9554 #[async_trait(?Send)]
9555 impl AncestorFetcher for ProofOnlyRootFetcher {
9556 async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
9557 assert!(
9558 self.requested.get() > 0,
9559 "root proof request must precede ancestry"
9560 );
9561 Err(GASPError::RemoteError("raw source unavailable".to_string()))
9562 }
9563 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
9564 self.requested.set(self.requested.get() + 1);
9565 self.proofs.get(txid).cloned()
9566 }
9567 }
9568
9569 #[tokio::test]
9570 async fn copy_sync_leaves_completion_courier_untouched() {
9571 let (mut engine, mut remote) = budget_page_engine(1);
9572 remote.node_cost_ms = 0;
9573 let txid = remote.outputs[0].txid.clone();
9574 let proof = std::rc::Rc::make_mut(&mut remote.nodes)
9575 .get_mut(&txid)
9576 .unwrap()
9577 .proof
9578 .take()
9579 .unwrap();
9580 let completion_asks = std::rc::Rc::new(Cell::new(0));
9581 let sync_asks = std::rc::Rc::new(Cell::new(0));
9582 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9583 proofs: HashMap::new(),
9584 requested: completion_asks.clone(),
9585 }));
9586 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9587 proofs: HashMap::from([(txid.clone(), proof.clone())]),
9588 requested: sync_asks.clone(),
9589 }));
9590 engine.set_gasp_remote_factory(Box::new(remote));
9591 let result = engine.start_gasp_sync().await.unwrap();
9592 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
9593 assert_eq!(result.topics_synced["tm_test"].roots_proven, 1);
9594 assert_eq!(result.topics_synced["tm_test"].roots_unproven, 0);
9595 let stored = engine
9596 .storage()
9597 .find_output(&txid, 0, Some("tm_test"), None, true)
9598 .await
9599 .unwrap()
9600 .unwrap();
9601 assert!(
9602 crate::beef_limits::own_proof(stored.beef.as_ref().unwrap(), &txid)
9603 .unwrap()
9604 .is_some()
9605 );
9606 assert_eq!(completion_asks.get(), 0);
9607 assert_eq!(sync_asks.get(), 1);
9608 let served = engine
9609 .provide_foreign_gasp_node(&format!("{txid}.0"), &txid, 0)
9610 .await
9611 .unwrap();
9612 assert_eq!(served.proof, Some(proof));
9613 }
9614
9615 #[tokio::test]
9616 async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
9617 for proven in [true, false] {
9618 let (mut engine, mut remote) = budget_page_engine(1);
9619 let txid = remote.outputs[0].txid.clone();
9620 let proof = remote.nodes[&txid].proof.clone().unwrap();
9621 std::rc::Rc::make_mut(&mut remote.nodes)
9622 .get_mut(&txid)
9623 .unwrap()
9624 .proof = None;
9625 let requested = std::rc::Rc::new(Cell::new(0));
9626 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9627 proofs: if proven {
9628 HashMap::from([(txid.clone(), proof)])
9629 } else {
9630 HashMap::new()
9631 },
9632 requested: requested.clone(),
9633 }));
9634 virtual_peer_budget(&mut engine, &remote);
9635 engine.set_gasp_remote_factory(Box::new(remote));
9636 let result = engine.start_gasp_sync().await.unwrap();
9637 assert!(requested.get() > 0);
9638 assert_eq!(
9639 result.topics_synced["tm_test"].finalized_graphs,
9640 u64::from(proven)
9641 );
9642 assert_eq!(
9643 engine
9644 .storage
9645 .find_output(&txid, 0, Some("tm_test"), None, true)
9646 .await
9647 .unwrap()
9648 .is_some(),
9649 proven
9650 );
9651 if !proven {
9652 assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
9653 }
9654 }
9655 }
9656
9657 #[tokio::test]
9658 async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
9659 let (mut engine, mut remote) = budget_page_engine(1);
9660 let txid = remote.outputs[0].txid.clone();
9661 let parent = bsv_rs::transaction::Transaction::from_hex(&remote.nodes[&txid].raw_tx)
9665 .unwrap()
9666 .inputs[0]
9667 .source_txid
9668 .clone()
9669 .unwrap();
9670 remote.faults_for.borrow_mut().insert(parent);
9671 let proof = remote.nodes[&txid].proof.clone().unwrap();
9672 std::rc::Rc::make_mut(&mut remote.nodes)
9673 .get_mut(&txid)
9674 .unwrap()
9675 .proof = None;
9676 let requested = std::rc::Rc::new(Cell::new(0));
9677 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9678 proofs: HashMap::new(),
9679 requested: requested.clone(),
9680 }));
9681 virtual_peer_budget(&mut engine, &remote);
9682 engine.set_graph_budget(
9683 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9684 5,
9685 5000,
9686 );
9687 engine.set_gasp_remote_factory(Box::new(remote));
9688 let held = engine.start_gasp_sync().await.unwrap();
9689 assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
9690 assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
9691 assert_eq!(held.topics_synced["tm_test"].cursor_moves[0].to, 10);
9692 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9693 proofs: HashMap::from([(txid.clone(), proof)]),
9694 requested,
9695 }));
9696 engine
9697 .storage()
9698 .update_last_interaction("https://budget.example", "tm_test", 20)
9699 .await
9700 .unwrap();
9701 let landed = engine.start_gasp_sync().await.unwrap();
9702 assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
9703 assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
9704 assert!(landed.topics_synced["tm_test"].cursor_moves.is_empty());
9705 assert!(engine
9706 .storage
9707 .find_output(&txid, 0, Some("tm_test"), None, true)
9708 .await
9709 .unwrap()
9710 .is_some());
9711 }
9712
9713 #[tokio::test]
9714 async fn proofs_deadline_stalls_become_durable_after_three_runs() {
9715 let (mut engine, remote) = budget_page_engine(1);
9716 let elapsed = remote.elapsed.clone();
9717 let root = format!("{}.0", remote.outputs[0].txid);
9718 engine.set_graph_budget(
9719 std::rc::Rc::new(move |ms| {
9720 let until = elapsed.get() + ms;
9721 let elapsed = elapsed.clone();
9722 Box::pin(std::future::poll_fn(move |_| {
9723 if elapsed.get() >= until {
9724 std::task::Poll::Ready(())
9725 } else {
9726 std::task::Poll::Pending
9727 }
9728 }))
9729 }),
9730 10,
9731 1_000,
9732 );
9733 engine.set_gasp_remote_factory(Box::new(remote));
9734 for round in 1..=crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9735 let result = engine.start_gasp_sync().await.unwrap();
9736 let topic = &result.topics_synced["tm_test"];
9737 if round < crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9738 assert!(topic.cursor_moves.is_empty());
9739 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
9740 } else {
9741 assert_eq!(topic.cursor_moves[0].to, 10);
9742 assert_eq!(topic.deferred_graphs, 1);
9743 }
9744 }
9745 let held = engine
9746 .storage()
9747 .get_deferred_graph("https://budget.example", "tm_test", &root)
9748 .await
9749 .unwrap()
9750 .unwrap();
9751 assert!(held.cursor_passable);
9752 assert!(held.nodes.is_empty());
9753 }
9754
9755 #[tokio::test]
9761 async fn cursor_018_the_graph_in_flight_at_the_peer_deadline_is_held_and_passed() {
9762 let (mut engine, remote) = budget_page_engine(6);
9763 let slow = remote.outputs[2].txid.clone();
9764 let root = format!("{slow}.0");
9765 *remote.slow_once.borrow_mut() = Some((slow.clone(), 60_000));
9766 virtual_peer_budget(&mut engine, &remote);
9767 engine.set_graph_budget(
9768 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9769 100,
9770 60_000,
9771 );
9772 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9773 let result = engine.start_gasp_sync().await.unwrap();
9774 let topic = &result.topics_synced["tm_test"];
9775 assert_eq!(topic.finalized_graphs, 2);
9776 assert_eq!(topic.deadline_dropped_graphs, 1);
9777 assert_eq!(topic.deferred_graphs, 1);
9778 assert_eq!(topic.cursor_moves.len(), 1);
9779 assert_eq!(topic.cursor_moves[0].to, 30, "past the held graph");
9780 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
9781 let held = engine
9782 .storage()
9783 .get_deferred_graph("https://budget.example", "tm_test", &root)
9784 .await
9785 .unwrap()
9786 .unwrap();
9787 assert!(held.cursor_passable);
9788 assert_eq!(held.reason, "peer_deadline");
9789 assert!(held.nodes.is_empty());
9790 assert_eq!(held.score, 30);
9791
9792 let result = engine.start_gasp_sync().await.unwrap();
9793 let topic = &result.topics_synced["tm_test"];
9794 assert_eq!(topic.resumed_graphs, 1);
9795 assert_eq!(topic.converged_graphs, 1);
9796 assert_eq!(topic.finalized_graphs, 4, "the held graph and 40, 50, 60");
9797 assert_eq!(topic.cursor_moves[0].to, 60);
9798 assert!(engine
9799 .storage()
9800 .get_deferred_graph("https://budget.example", "tm_test", &root)
9801 .await
9802 .unwrap()
9803 .is_none());
9804 for output in remote.outputs.iter() {
9805 assert!(engine
9806 .storage
9807 .find_output(&output.txid, 0, Some("tm_test"), None, true)
9808 .await
9809 .unwrap()
9810 .is_some());
9811 }
9812 }
9813
9814 struct ExpiredRoots(MockTopicManager);
9816
9817 #[async_trait(?Send)]
9818 impl TopicManagerTrait for ExpiredRoots {
9819 async fn identify_admissible_outputs(
9820 &self,
9821 tx: &Transaction,
9822 previous_coins: &[u8],
9823 off_chain_values: Option<&[u8]>,
9824 mode: SubmitMode,
9825 context: &TopicAdmittanceContext,
9826 ) -> Result<AdmittanceInstructions, TopicManagerError> {
9827 self.0
9828 .identify_admissible_outputs(tx, previous_coins, off_chain_values, mode, context)
9829 .await
9830 }
9831 async fn get_documentation(&self) -> String {
9832 self.0.get_documentation().await
9833 }
9834 async fn get_metadata(&self) -> ServiceMetadata {
9835 self.0.get_metadata().await
9836 }
9837 fn sync_expiry(&self, _: &bsv_rs::script::LockingScript) -> Option<u64> {
9838 Some(1)
9839 }
9840 }
9841
9842 fn dead_page_engine(count: u32) -> (Engine, BudgetPageRemote, std::rc::Rc<MemoryStorage>) {
9845 let (mut engine, mut remote) = budget_page_engine(count);
9846 let gap = remote.outputs[0].txid.clone();
9847 std::rc::Rc::make_mut(&mut remote.nodes).remove(&gap);
9848 engine.managers.insert(
9849 "tm_test".to_string(),
9850 Box::new(ExpiredRoots(MockTopicManager::admitting(vec![0]))),
9851 );
9852 let store = std::rc::Rc::new(MemoryStorage::new());
9853 engine.storage = Box::new(store.clone());
9854 engine.set_graph_budget(
9855 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9856 100,
9857 60_000,
9858 );
9859 (engine, remote, store)
9860 }
9861
9862 #[tokio::test]
9866 async fn cursor_018_a_sync_that_only_refuses_the_dead_is_a_yield() {
9867 let (mut engine, mut remote, store) = dead_page_engine(3);
9868 remote.node_cost_ms = 0;
9869 virtual_peer_budget(&mut engine, &remote);
9870 engine.set_gasp_remote_factory(Box::new(remote));
9871 let result = engine.start_gasp_sync().await.unwrap();
9872 let topic = &result.topics_synced["tm_test"];
9873 assert_eq!(topic.expired_refused, 2);
9874 assert_eq!(topic.finalized_graphs, 0);
9875 assert!(topic.cursor_moves.is_empty());
9876 let host = crate::gasp::peer_origin("https://budget.example");
9877 assert_eq!(store.peer_yieldless_syncs(&host, "tm_test"), 0);
9878 let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9879 assert_eq!(health.consecutive_failures, 0);
9880 }
9881
9882 #[tokio::test]
9888 async fn cursor_018_deadline_drops_in_a_row_do_not_quarantine_a_serving_peer() {
9889 let (mut engine, remote, store) = dead_page_engine(200);
9890 virtual_peer_budget(&mut engine, &remote);
9891 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9892 let host = crate::gasp::peer_origin("https://budget.example");
9893 let runs = crate::gasp::PEER_QUARANTINE_THRESHOLD + 1;
9894 let gap = format!("{}.0", remote.outputs[0].txid);
9895 for run in 1..=runs {
9896 if let Some(mut record) = store
9900 .get_deferred_graph("https://budget.example", "tm_test", &gap)
9901 .await
9902 .unwrap()
9903 .filter(|r| r.reason.starts_with("retry:fault:"))
9904 {
9905 record.retry_at = 0;
9906 store.put_deferred_graph(&record).await.unwrap();
9907 }
9908 let asked = remote.asked.borrow().len();
9909 let result = engine.start_gasp_sync().await.unwrap();
9910 let topic = &result.topics_synced["tm_test"];
9911 assert!(remote.asked.borrow().len() > asked, "run {run}: attempted");
9912 assert_eq!(topic.deadline_dropped_graphs, 1, "run {run}");
9913 assert!(topic.expired_refused > 0, "run {run}: {topic:?}");
9914 if run < u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9918 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
9919 } else if run == u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9920 assert_eq!(topic.unanchorable_graphs, 1, "run {run}: {topic:?}");
9921 assert!(!topic.cursor_moves.is_empty(), "run {run}: passed");
9922 }
9923 let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9924 assert_eq!(health.consecutive_failures, 0, "run {run}");
9925 }
9926 let asked = remote.asked.borrow();
9930 let mut dead: Vec<&String> = asked
9931 .iter()
9932 .filter(|t| **t != remote.outputs[0].txid)
9933 .collect();
9934 let fetched = dead.len();
9935 dead.sort();
9936 dead.dedup();
9937 assert_eq!(
9938 fetched - dead.len(),
9939 (runs - 1) as usize,
9940 "one per resumed cut"
9941 );
9942 assert_eq!(store.expired_outpoints("tm_test").len(), dead.len() - 1);
9943 }
9944
9945 struct GatedCourier {
9948 txid: String,
9949 raw_tx: String,
9950 proof: String,
9951 serve: std::rc::Rc<Cell<bool>>,
9952 asked: std::rc::Rc<Cell<u64>>,
9953 }
9954
9955 #[async_trait(?Send)]
9956 impl AncestorFetcher for GatedCourier {
9957 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
9958 self.asked.set(self.asked.get() + 1);
9959 if self.serve.get() && txid == self.txid {
9960 Ok(FetchedAncestor {
9961 raw_tx: self.raw_tx.clone(),
9962 proof: Some(self.proof.clone()),
9963 })
9964 } else {
9965 Err(GASPError::RemoteError(format!(
9966 "no rung served the raw of {txid}"
9967 )))
9968 }
9969 }
9970 async fn verified_proof_for(&self, _: &str) -> Option<String> {
9971 None
9972 }
9973 }
9974
9975 #[tokio::test]
9979 async fn load_0112_definitive_absence_strikes_a_resumed_obligation() {
9980 let mut fixture = obligation_fixture(1, 1, 0).await;
9981 fixture
9982 .engine
9983 .set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
9984 calls: std::rc::Rc::new(Cell::new(0)),
9985 bounded: false,
9986 }));
9987 let host = "https://budget.example";
9991 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
9992 for run in 1..=runs {
9993 let result = fixture.engine.start_gasp_sync().await.unwrap();
9994 let topic = &result.topics_synced["tm_test"];
9995 let record = fixture
9996 .store
9997 .get_deferred_graph(host, "tm_test", &fixture.obligations[0])
9998 .await
9999 .unwrap()
10000 .unwrap();
10001 assert_eq!(record.faults, run);
10002 assert!(record.cursor_passable);
10003 if run == 1 {
10004 assert_eq!(topic.finalized_graphs, 1);
10005 }
10006 if run < runs {
10007 assert_eq!(topic.unanchorable_graphs, 0);
10008 assert_eq!(record.reason, "retry:fault:not_found");
10009 let mut record = record;
10010 record.retry_at = 0;
10011 fixture.store.put_deferred_graph(&record).await.unwrap();
10012 } else {
10013 assert_eq!(topic.unanchorable_graphs, 1);
10014 assert_eq!(record.reason, "unanchorable:not_found");
10015 }
10016 }
10017 }
10018
10019 #[tokio::test]
10020 async fn load_0112_root_time_is_clamped_and_named() {
10021 let (mut engine, mut remote) = budget_page_engine(1);
10022 remote.node_cost_ms = 16_000;
10023 let elapsed = remote.elapsed.clone();
10024 let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10025 let clock = elapsed.clone();
10026 let due = clock.get() + ms;
10027 Box::pin(std::future::poll_fn(move |_| {
10028 if clock.get() >= due {
10029 std::task::Poll::Ready(())
10030 } else {
10031 std::task::Poll::Pending
10032 }
10033 }))
10034 });
10035 engine.set_peer_sync_budget(sleep.clone(), 30_000);
10036 engine.set_graph_budget(sleep, 100, 60_000);
10037 engine.set_gasp_remote_factory(Box::new(remote));
10038 let result = engine.start_gasp_sync().await.unwrap();
10039 let topic = &result.topics_synced["tm_test"];
10040 assert_eq!(topic.finalized_graphs, 0);
10041 assert_eq!(topic.root_bounds[0].reason, "time");
10042 assert!(!topic.cursor_held_by.is_empty() || topic.deferred_graphs > 0);
10043 }
10044
10045 #[tokio::test]
10046 async fn load_0112_storage_counts_are_attempts_at_the_boundary() {
10047 let engine = make_engine(vec![0]);
10048 let meter = crate::meter::LoadMeter::default();
10049 meter
10050 .scope(async {
10051 assert_eq!(
10052 engine
10053 .storage
10054 .get_last_interaction("peer", "topic")
10055 .await
10056 .unwrap(),
10057 0
10058 );
10059 engine
10060 .storage
10061 .update_last_interaction("peer", "topic", 12)
10062 .await
10063 .unwrap();
10064 assert_eq!(
10065 engine
10066 .storage
10067 .get_last_interaction("peer", "topic")
10068 .await
10069 .unwrap(),
10070 12
10071 );
10072 })
10073 .await;
10074 assert_eq!(
10075 (
10076 meter.snapshot().storage_reads,
10077 meter.snapshot().storage_writes
10078 ),
10079 (2, 1)
10080 );
10081 engine
10082 .storage
10083 .get_last_interaction("peer", "topic")
10084 .await
10085 .unwrap();
10086 assert_eq!(meter.snapshot().storage_reads, 2);
10087 }
10088
10089 struct LoadCourier {
10090 calls: std::rc::Rc<Cell<u64>>,
10091 bounded: bool,
10092 }
10093 #[async_trait(?Send)]
10094 impl crate::gasp::AncestorFetcher for LoadCourier {
10095 async fn fetch_ancestor(
10096 &self,
10097 txid: &str,
10098 ) -> Result<crate::gasp::FetchedAncestor, crate::gasp::GASPError> {
10099 self.calls.set(self.calls.get() + 1);
10100 if self.bounded {
10101 for _ in 0..13 {
10102 crate::meter::courier_request()
10103 .map_err(|e| crate::gasp::GASPError::Other(e.into()))?;
10104 }
10105 Err(crate::gasp::GASPError::RemoteError(
10106 "still missing a proof".into(),
10107 ))
10108 } else {
10109 for _ in 0..3 {
10110 crate::meter::courier_request().unwrap();
10111 }
10112 Err(crate::gasp::GASPError::DefinitiveNotFound(txid.into()))
10113 }
10114 }
10115 async fn verified_proof_for(&self, _: &str) -> Option<String> {
10116 None
10117 }
10118 }
10119
10120 #[tokio::test]
10121 async fn load_0112_definitive_absence_is_shared_across_peers_and_topics() {
10122 let UnanchorableFixture {
10123 mut engine,
10124 remote,
10125 root,
10126 ..
10127 } = unanchorable_fixture();
10128 let calls = std::rc::Rc::new(Cell::new(0));
10129 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10130 calls: calls.clone(),
10131 bounded: false,
10132 }));
10133 engine.managers.insert(
10134 "tm_other".into(),
10135 Box::new(MockTopicManager::admitting(vec![0])),
10136 );
10137 for topic in ["tm_test", "tm_other"] {
10138 engine.config.sync_configuration.insert(
10139 topic.into(),
10140 SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
10141 );
10142 }
10143 let result = engine.start_gasp_sync().await.unwrap();
10144 assert_eq!(calls.get(), 1, "one ladder per missing ancestor per run");
10145 assert_eq!(result.cost.courier_not_found_memo_hits, 3);
10146 assert_eq!(result.cost.courier_rungs, 3);
10147 for (topic, res) in &result.topics_synced {
10148 assert_eq!(res.unanchorable_graphs, 0, "{res:?}");
10151 assert_eq!(
10152 res.cost.courier_rungs + res.cost.peer_requests,
10153 res.cost.calls_out
10154 );
10155 for host in ["https://a.example", "https://b.example"] {
10156 let mut record = engine
10157 .storage
10158 .get_deferred_graph(host, topic, &root)
10159 .await
10160 .unwrap()
10161 .unwrap();
10162 assert_eq!(record.reason, "retry:fault:not_found");
10163 assert_eq!(record.faults, 1);
10164 assert!(!record.cursor_passable);
10165 record.retry_at = 0;
10166 engine.storage.put_deferred_graph(&record).await.unwrap();
10167 }
10168 }
10169 assert!(!remote.asked.borrow().is_empty());
10170 engine.start_gasp_sync().await.unwrap();
10171 assert_eq!(calls.get(), 2, "another run can retry absence");
10172 }
10173
10174 #[tokio::test]
10175 async fn load_0112_courier_bound_defers_the_root_and_names_it() {
10176 let UnanchorableFixture {
10177 mut engine, root, ..
10178 } = unanchorable_fixture();
10179 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10180 calls: std::rc::Rc::new(Cell::new(0)),
10181 bounded: true,
10182 }));
10183 let result = engine.start_gasp_sync().await.unwrap();
10184 let record = engine
10185 .storage
10186 .get_deferred_graph("https://budget.example", "tm_test", &root)
10187 .await
10188 .unwrap()
10189 .expect("bounded root must be retained");
10190 assert_eq!(record.reason, "courier_rungs");
10191 assert!(!record.nodes.is_empty());
10192 assert_eq!(result.cost.courier_rungs, 12);
10193 assert_eq!(
10194 result.topics_synced["tm_test"].root_bounds[0].reason,
10195 "courier_rungs"
10196 );
10197 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 2);
10198 }
10199
10200 #[tokio::test]
10201 async fn load_0112_refused_save_does_not_restart_a_bounded_root() {
10202 let UnanchorableFixture {
10203 mut engine,
10204 store,
10205 root,
10206 ..
10207 } = unanchorable_fixture();
10208 store.set_deferred_graph_ceiling(Some(0));
10209 let calls = std::rc::Rc::new(Cell::new(0));
10210 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10211 calls: calls.clone(),
10212 bounded: true,
10213 }));
10214 let result = engine.start_gasp_sync().await.unwrap();
10215 let topic = &result.topics_synced["tm_test"];
10216 assert_eq!(calls.get(), 1, "no retry after a hard root cut");
10217 assert_eq!(topic.root_bounds.len(), 1);
10218 assert_eq!(result.cost.courier_rungs, 12);
10219 assert!(topic.cursor_held_by.contains(&root));
10220 assert_eq!(
10221 topic.strikes_refused, 0,
10222 "a budget cut is not a fault strike"
10223 );
10224 assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10225 assert_eq!(topic.finalized_graphs, 2);
10226 }
10227
10228 #[tokio::test]
10229 async fn load_0112_refused_save_does_not_extend_root_time() {
10230 let (mut engine, mut remote) = budget_page_engine(1);
10231 remote.node_cost_ms = 16_000;
10232 let elapsed = remote.elapsed.clone();
10233 let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10234 let clock = elapsed.clone();
10235 let due = clock.get() + ms;
10236 Box::pin(std::future::poll_fn(move |_| {
10237 if clock.get() >= due {
10238 std::task::Poll::Ready(())
10239 } else {
10240 std::task::Poll::Pending
10241 }
10242 }))
10243 });
10244 let store = std::rc::Rc::new(MemoryStorage::new());
10245 store.set_deferred_graph_ceiling(Some(0));
10246 engine.storage = Box::new(store);
10247 engine.set_peer_sync_budget(sleep.clone(), 30_000);
10248 engine.set_graph_budget(sleep, 100, 60_000);
10249 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10250 let result = engine.start_gasp_sync().await.unwrap();
10251 let topic = &result.topics_synced["tm_test"];
10252 assert_eq!(
10253 remote.asked.borrow().len(),
10254 1,
10255 "no request after the time cut"
10256 );
10257 assert_eq!(topic.root_bounds.len(), 1);
10258 assert_eq!(topic.root_bounds[0].reason, "time");
10259 assert_eq!(topic.finalized_graphs, 0);
10260 assert!(!topic.cursor_held_by.is_empty());
10261 }
10262
10263 #[tokio::test]
10264 async fn load_0112_call_limit_fallback_keeps_the_root_deadline() {
10265 let UnanchorableFixture {
10266 mut engine,
10267 mut remote,
10268 store,
10269 root,
10270 serve,
10271 ..
10272 } = unanchorable_fixture();
10273 store.set_deferred_graph_ceiling(Some(0));
10274 serve.set(true);
10275 remote.node_cost_ms = 8_000;
10276 struct DelayedCourier {
10277 inner: std::rc::Rc<dyn AncestorFetcher>,
10278 clock: std::rc::Rc<Cell<u64>>,
10279 }
10280 #[async_trait(?Send)]
10281 impl AncestorFetcher for DelayedCourier {
10282 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10283 self.clock.set(self.clock.get() + 8_000);
10284 tokio::task::yield_now().await;
10285 self.inner.fetch_ancestor(txid).await
10286 }
10287 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
10288 self.inner.verified_proof_for(txid).await
10289 }
10290 }
10291 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(DelayedCourier {
10292 inner: engine.gasp_ancestor_fetcher.as_ref().unwrap().clone(),
10293 clock: remote.elapsed.clone(),
10294 }));
10295 let elapsed = remote.elapsed.clone();
10296 let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10297 let clock = elapsed.clone();
10298 let due = clock.get() + ms;
10299 Box::pin(std::future::poll_fn(move |_| {
10300 if clock.get() >= due {
10301 std::task::Poll::Ready(())
10302 } else {
10303 std::task::Poll::Pending
10304 }
10305 }))
10306 });
10307 engine.set_peer_sync_budget(sleep.clone(), 30_000);
10308 engine.set_graph_budget(sleep, 1, 60_000);
10309 engine.set_gasp_remote_factory(Box::new(remote));
10310 let result = engine.start_gasp_sync().await.unwrap();
10311 let topic = &result.topics_synced["tm_test"];
10312 assert!(
10313 topic
10314 .root_bounds
10315 .iter()
10316 .any(|b| b.outpoint == root && b.reason == "time"),
10317 "{topic:?}"
10318 );
10319 assert!(topic.cursor_held_by.contains(&root));
10320 assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10321 }
10322
10323 #[tokio::test]
10324 async fn load_0112_cost_matches_the_remote_call_log() {
10325 let (mut engine, remote) = budget_page_engine(2);
10326 remote.elapsed.set(0);
10327 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10328 let result = engine.start_gasp_sync().await.unwrap();
10329 let actual_calls = (remote.requests.borrow().len() + remote.asked.borrow().len()) as u64;
10330 assert_eq!(result.cost.peer_requests, actual_calls);
10331 assert_eq!(result.cost.calls_out, actual_calls);
10332 assert!(result.cost.storage_reads > 0);
10333 assert!(result.cost.storage_writes > 0);
10334 assert_eq!(
10335 result.cost.storage_reads,
10336 result.topics_synced["tm_test"].cost.storage_reads
10337 );
10338 assert_eq!(
10339 result.cost.storage_writes,
10340 result.topics_synced["tm_test"].cost.storage_writes
10341 );
10342 }
10343
10344 struct UnanchorableFixture {
10345 engine: Engine,
10346 remote: BudgetPageRemote,
10347 store: std::rc::Rc<MemoryStorage>,
10348 root: String,
10349 serve: std::rc::Rc<Cell<bool>>,
10350 asked: std::rc::Rc<Cell<u64>>,
10351 }
10352
10353 fn unanchorable_fixture() -> UnanchorableFixture {
10354 let (mut engine, mut remote) = budget_page_engine(3);
10355 remote.node_cost_ms = 0;
10356 let parent = child_of(&format!("{:064x}", 0xab), 0);
10357 let child = child_of(&parent.id(), 0);
10358 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
10359 for n in 1..=3u32 {
10360 tracker.add_root(n, child_of(&format!("{n:064x}"), 0).id());
10361 }
10362 tracker.add_root(900, parent.id());
10363 engine.chain_tracker = Some(Box::new(tracker));
10364 let replaced = std::mem::replace(
10365 &mut std::rc::Rc::make_mut(&mut remote.outputs)[0].txid,
10366 child.id(),
10367 );
10368 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10369 nodes.remove(&replaced);
10370 nodes.insert(
10371 child.id(),
10372 crate::types::GASPNode {
10373 graph_id: String::new(),
10374 raw_tx: child.to_hex(),
10375 output_index: 0,
10376 proof: None,
10377 tx_metadata: None,
10378 output_metadata: None,
10379 inputs: None,
10380 },
10381 );
10382 remote.faults_for.borrow_mut().insert(parent.id());
10387 let serve = std::rc::Rc::new(Cell::new(false));
10388 let asked = std::rc::Rc::new(Cell::new(0));
10389 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(GatedCourier {
10390 txid: parent.id(),
10391 raw_tx: parent.to_hex(),
10392 proof: single_leaf_bump_hex(&parent.id(), 900),
10393 serve: serve.clone(),
10394 asked: asked.clone(),
10395 }));
10396 let store = std::rc::Rc::new(MemoryStorage::new());
10397 engine.storage = Box::new(store.clone());
10398 virtual_peer_budget(&mut engine, &remote);
10399 engine.set_graph_budget(
10400 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10401 100,
10402 60_000,
10403 );
10404 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10405 UnanchorableFixture {
10406 engine,
10407 remote,
10408 store,
10409 root: format!("{}.0", child.id()),
10410 serve,
10411 asked,
10412 }
10413 }
10414
10415 #[tokio::test]
10424 async fn unanchorable_019_a_root_faulted_three_runs_is_recorded_passed_and_admitted_later() {
10425 let UnanchorableFixture {
10426 engine,
10427 remote,
10428 store,
10429 root,
10430 serve,
10431 asked,
10432 } = unanchorable_fixture();
10433 let host = "https://budget.example";
10434 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10435 for run in 1..=runs {
10436 let result = engine.start_gasp_sync().await.unwrap();
10437 let topic = &result.topics_synced["tm_test"];
10438 assert_eq!(topic.faulted_roots.len(), 1, "run {run}: {topic:?}");
10439 let named = &topic.faulted_roots[0];
10440 assert_eq!(
10441 (named.peer.as_str(), named.outpoint.as_str()),
10442 (host, root.as_str())
10443 );
10444 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10445 assert_eq!(named.faults, run, "run {run}");
10446 assert!(named.error.contains("no rung served"), "{named:?}");
10447 let record = engine
10448 .storage()
10449 .get_deferred_graph(host, "tm_test", &root)
10450 .await
10451 .unwrap()
10452 .unwrap();
10453 assert_eq!(record.faults, run);
10454 assert!(record.nodes.is_empty(), "the root alone, asked again");
10455 if run < runs {
10456 assert!(!named.unanchorable);
10457 assert_eq!(topic.unanchorable_graphs, 0);
10458 assert_eq!(record.reason, "retry:fault:courier");
10459 assert!(!record.cursor_passable);
10460 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10461 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10462 } else {
10463 assert!(named.unanchorable);
10464 assert_eq!(topic.unanchorable_graphs, 1);
10465 assert_eq!(record.reason, "unanchorable:courier");
10466 assert!(record.cursor_passable);
10467 assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
10468 assert_eq!(topic.cursor_moves.len(), 1);
10469 assert_eq!(
10470 topic.cursor_moves[0].to, 30,
10471 "the page's last finished score"
10472 );
10473 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10474 }
10475 if run == 1 {
10476 assert_eq!(topic.finalized_graphs, 2, "20 and 30");
10477 }
10478 }
10479
10480 let (courier_asks, node_asks) = (asked.get(), remote.asked.borrow().len());
10483 let streak = store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test");
10484 let result = engine.start_gasp_sync().await.unwrap();
10485 let topic = &result.topics_synced["tm_test"];
10486 assert!(topic.faulted_roots.is_empty(), "{topic:?}");
10487 assert!(topic.cursor_held_by.is_empty());
10488 assert_eq!(topic.unanchorable_graphs, 0);
10489 assert_eq!(asked.get(), courier_asks);
10490 assert_eq!(remote.asked.borrow().len(), node_asks);
10491 assert_eq!(
10492 store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test"),
10493 streak
10494 );
10495
10496 let mut record = engine
10499 .storage()
10500 .get_deferred_graph(host, "tm_test", &root)
10501 .await
10502 .unwrap()
10503 .unwrap();
10504 record.retry_at = 1;
10505 engine.storage().put_deferred_graph(&record).await.unwrap();
10506 serve.set(true);
10507 let result = engine.start_gasp_sync().await.unwrap();
10508 let topic = &result.topics_synced["tm_test"];
10509 assert_eq!(topic.finalized_graphs, 1, "{topic:?}");
10510 assert_eq!(topic.unanchorable_cleared, 1);
10511 assert!(topic.faulted_roots.is_empty());
10512 assert!(engine
10513 .storage()
10514 .get_deferred_graph(host, "tm_test", &root)
10515 .await
10516 .unwrap()
10517 .is_none());
10518 let (txid, _) = crate::gasp::parse_outpoint(&root).unwrap();
10519 assert!(engine
10520 .storage
10521 .find_output(&txid, 0, Some("tm_test"), None, true)
10522 .await
10523 .unwrap()
10524 .is_some());
10525 }
10526
10527 #[tokio::test]
10532 async fn unanchorable_019_only_the_lowest_faulted_root_is_recorded_and_all_are_named() {
10533 let (mut engine, mut remote) = budget_page_engine(4);
10534 remote.node_cost_ms = 0;
10535 for idx in [1usize, 3] {
10543 let parent = child_of(&format!("{:064x}", 0xdead_0000 + idx), 0);
10544 let child = child_of(&parent.id(), 0);
10545 let old = std::mem::replace(
10546 &mut std::rc::Rc::make_mut(&mut remote.outputs)[idx].txid,
10547 child.id(),
10548 );
10549 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10550 nodes.remove(&old);
10551 nodes.insert(
10552 child.id(),
10553 crate::types::GASPNode {
10554 graph_id: String::new(),
10555 raw_tx: child.to_hex(),
10556 output_index: 0,
10557 proof: None,
10558 tx_metadata: None,
10559 output_metadata: None,
10560 inputs: None,
10561 },
10562 );
10563 remote.faults_for.borrow_mut().insert(parent.id());
10564 }
10565 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
10566 asked: std::rc::Rc::default(),
10567 transient: std::rc::Rc::default(),
10568 }));
10569 let store = std::rc::Rc::new(MemoryStorage::new());
10570 engine.storage = Box::new(store.clone());
10571 virtual_peer_budget(&mut engine, &remote);
10572 engine.set_graph_budget(
10573 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10574 100,
10575 60_000,
10576 );
10577 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10578 let result = engine.start_gasp_sync().await.unwrap();
10579 let topic = &result.topics_synced["tm_test"];
10580 let named: Vec<(&str, &str, u32)> = topic
10581 .faulted_roots
10582 .iter()
10583 .map(|f| (f.outpoint.as_str(), f.class.as_str(), f.faults))
10584 .collect();
10585 let low = format!("{}.0", remote.outputs[1].txid);
10586 let high = format!("{}.0", remote.outputs[3].txid);
10587 assert_eq!(
10588 named,
10589 vec![
10590 (low.as_str(), crate::gasp::fault_class::COURIER, 1),
10591 (high.as_str(), crate::gasp::fault_class::COURIER, 0),
10592 ]
10593 );
10594 assert_eq!(topic.cursor_moves[0].to, 10, "below the lower gap");
10595 let host = "https://budget.example";
10596 assert!(store
10597 .get_deferred_graph(host, "tm_test", &high)
10598 .await
10599 .unwrap()
10600 .is_none());
10601 assert_eq!(
10602 store
10603 .get_deferred_graph(host, "tm_test", &low)
10604 .await
10605 .unwrap()
10606 .unwrap()
10607 .reason,
10608 "retry:fault:courier"
10609 );
10610 }
10611
10612 fn bare_record(n: u32, score: u64, reason: &str, passable: bool) -> crate::gasp::DeferredGraph {
10615 let outpoint = format!("{:064x}.0", 0xdead_0000 + n);
10616 crate::gasp::DeferredGraph {
10617 peer: "https://budget.example".into(),
10618 topic: "tm_test".into(),
10619 outpoint: outpoint.clone(),
10620 score,
10621 nodes: Vec::new(),
10622 pending: vec![crate::gasp::PendingInput {
10623 outpoint: outpoint.clone(),
10624 graph_id: outpoint,
10625 metadata: true,
10626 spent_by: None,
10627 parent_proven: false,
10628 }],
10629 calls: 0,
10630 passes: 0,
10631 reason: reason.into(),
10632 configured: true,
10633 idle_faults: 0,
10634 cursor_passable: passable,
10635 faults: 0,
10636 retry_at: 0,
10637 idle_resumes: 0,
10638 unanchorable_retries: 0,
10639 expiry: None,
10640 }
10641 }
10642
10643 async fn hold_obligations(engine: &Engine, count: usize) -> Vec<String> {
10647 let mut roots = Vec::new();
10648 for n in 0..count {
10649 let record = bare_record(n as u32, 1 + n as u64, "retry", true);
10650 roots.push(record.outpoint.clone());
10651 engine.storage().put_deferred_graph(&record).await.unwrap();
10652 }
10653 roots
10654 }
10655
10656 #[tokio::test]
10664 async fn room_0110_a_full_store_still_records_the_cursors_holder_and_passes_it() {
10665 let UnanchorableFixture {
10666 engine,
10667 store,
10668 root,
10669 ..
10670 } = unanchorable_fixture();
10671 let host = "https://budget.example";
10672 let obligations =
10673 hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10674 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10675 for run in 1..=runs {
10676 let result = engine.start_gasp_sync().await.unwrap();
10677 let topic = &result.topics_synced["tm_test"];
10678 assert_eq!(topic.strikes_refused, 0, "run {run}: {topic:?}");
10679 let named = topic
10680 .faulted_roots
10681 .iter()
10682 .find(|f| f.outpoint == root)
10683 .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
10684 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10685 assert_eq!(named.faults, run, "run {run}: the strike was written");
10686 assert!(named.refused.is_empty(), "{named:?}");
10687 if run < runs {
10688 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10689 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10690 } else {
10691 assert!(named.unanchorable);
10692 assert_eq!(topic.unanchorable_graphs, 1);
10693 assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10694 assert_eq!(topic.cursor_moves[0].to, 30, "passed");
10695 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10696 }
10697 }
10698 let held: Vec<String> = store
10700 .deferred_graphs()
10701 .into_iter()
10702 .map(|r| r.outpoint)
10703 .collect();
10704 assert_eq!(held.len(), obligations.len(), "{held:?}");
10706 assert_eq!(store.fault_memos().len(), 1);
10707 assert!(obligations.iter().all(|o| held.contains(o)));
10708 let record = store
10709 .get_deferred_graph(host, "tm_test", &root)
10710 .await
10711 .unwrap()
10712 .unwrap();
10713 assert_eq!(record.reason, "unanchorable:courier");
10714 }
10715
10716 #[tokio::test]
10723 async fn room_0110_a_refused_strike_is_counted_and_named() {
10724 let UnanchorableFixture {
10725 engine,
10726 store,
10727 root,
10728 ..
10729 } = unanchorable_fixture();
10730 store.set_fault_memo_ceiling(Some(0));
10732 let refused_as = |result: &super::GASPSyncResult, why: &str| {
10733 let topic = &result.topics_synced["tm_test"];
10734 assert_eq!(topic.strikes_refused, 1, "{topic:?}");
10735 let named = topic
10736 .faulted_roots
10737 .iter()
10738 .find(|f| f.outpoint == root)
10739 .unwrap_or_else(|| panic!("{topic:?}"));
10740 assert_eq!(named.refused, why, "{named:?}");
10741 assert_eq!(named.faults, 0, "no record holds the count");
10742 assert!(topic.cursor_moves.is_empty(), "the gap holds");
10743 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10744 };
10745 let result = engine.start_gasp_sync().await.unwrap();
10746 refused_as(&result, crate::gasp::strike_refused::NO_ROOM);
10747 assert!(store
10748 .get_deferred_graph("https://budget.example", "tm_test", &root)
10749 .await
10750 .unwrap()
10751 .is_none());
10752
10753 store.set_fault_memo_ceiling(None);
10755 store.set_deferred_graph_put_fault(true);
10756 let result = engine.start_gasp_sync().await.unwrap();
10757 refused_as(&result, crate::gasp::strike_refused::STORE_FAULT);
10758
10759 store.set_deferred_graph_put_fault(false);
10761 let result = engine.start_gasp_sync().await.unwrap();
10762 let topic = &result.topics_synced["tm_test"];
10763 assert_eq!(topic.strikes_refused, 0, "{topic:?}");
10764 assert!(store
10765 .get_deferred_graph("https://budget.example", "tm_test", &root)
10766 .await
10767 .unwrap()
10768 .is_some());
10769 }
10770
10771 #[tokio::test]
10778 async fn room_0110_idle_obligations_back_off_and_the_page_is_walked() {
10779 let (mut engine, mut remote) = budget_page_engine(3);
10780 remote.node_cost_ms = 2000;
10781 let store = std::rc::Rc::new(MemoryStorage::new());
10782 engine.storage = Box::new(store.clone());
10783 virtual_peer_budget(&mut engine, &remote);
10784 engine.set_graph_budget(
10785 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10786 100,
10787 60_000,
10788 );
10789 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10790 let obligations =
10791 hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10792 let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10793 for run in 1..=idle {
10797 let result = engine.start_gasp_sync().await.unwrap();
10798 let topic = &result.topics_synced["tm_test"];
10799 assert_eq!(topic.finalized_graphs, 0, "run {run}: {topic:?}");
10800 assert!(topic.cursor_moves.is_empty(), "run {run}");
10801 assert_eq!(topic.resumed_graphs, 15, "run {run}");
10802 assert_eq!(topic.waiting_graphs, 0, "run {run}");
10803 let expect = if run == idle { 15 } else { 0 };
10804 assert_eq!(topic.backed_off_graphs, expect, "run {run}: {topic:?}");
10805 }
10806 let result = engine.start_gasp_sync().await.unwrap();
10808 let topic = &result.topics_synced["tm_test"];
10809 assert_eq!(topic.waiting_graphs, 15, "{topic:?}");
10810 assert_eq!(topic.finalized_graphs, 3, "the page's fresh roots");
10811 assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10812 assert_eq!(topic.cursor_moves[0].to, 30);
10813 let mut backed_off = 15;
10815 for _ in 0..idle {
10816 let result = engine.start_gasp_sync().await.unwrap();
10817 backed_off += result.topics_synced["tm_test"].backed_off_graphs;
10818 }
10819 assert_eq!(backed_off, obligations.len() as u64);
10820 let asked = remote.asked.borrow().len();
10821 let result = engine.start_gasp_sync().await.unwrap();
10822 let topic = &result.topics_synced["tm_test"];
10823 assert_eq!(topic.waiting_graphs, obligations.len() as u64, "{topic:?}");
10824 assert_eq!(topic.resumed_graphs, 0);
10825 assert!(topic.dropped_graphs.is_empty(), "{topic:?}");
10826 assert_eq!(remote.asked.borrow().len(), asked, "no request");
10827 let now = (current_timestamp_ms() / 1000.0) as u64;
10829 let held = store.deferred_graphs();
10830 assert_eq!(held.len(), obligations.len());
10831 for record in &held {
10832 assert!(obligations.contains(&record.outpoint));
10833 assert!(record.cursor_passable);
10834 assert!(record.idle_resumes >= idle, "{record:?}");
10835 assert!(record.retry_at > now, "{record:?}");
10836 }
10837 }
10838
10839 #[tokio::test]
10847 async fn room_0110_a_waiting_record_whose_root_expired_is_swept() {
10848 let (mut engine, remote) = budget_page_engine(1);
10849 let store = std::rc::Rc::new(MemoryStorage::new());
10850 engine.storage = Box::new(store.clone());
10851 engine.set_graph_budget(
10852 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10853 100,
10854 60_000,
10855 );
10856 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10857 let now = (current_timestamp_ms() / 1000.0) as u64;
10858 let mut waiting = bare_record(0, 1, "retry", true);
10859 waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10860 waiting.retry_at = now + 3600;
10861 let dead = bare_record(1, 2, "retry", true);
10862 let mut dead_waiting = dead.clone();
10863 dead_waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10864 dead_waiting.retry_at = now + 3600;
10865 for record in [&waiting, &dead_waiting] {
10866 engine.storage().put_deferred_graph(record).await.unwrap();
10867 }
10868 engine
10869 .storage()
10870 .remember_expired_outpoint("tm_test", &dead.outpoint, 1)
10871 .await
10872 .unwrap();
10873 let result = engine.start_gasp_sync().await.unwrap();
10874 let topic = &result.topics_synced["tm_test"];
10875 assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
10876 assert_eq!(
10877 topic
10878 .dropped_graphs
10879 .iter()
10880 .map(|d| (d.outpoint.as_str(), d.reason.as_str()))
10881 .collect::<Vec<_>>(),
10882 vec![(dead.outpoint.as_str(), "refused")]
10883 );
10884 let held: Vec<String> = store
10885 .deferred_graphs()
10886 .into_iter()
10887 .map(|r| r.outpoint)
10888 .collect();
10889 assert_eq!(held, vec![waiting.outpoint.clone()]);
10890 let asked = remote.asked.borrow();
10891 assert!(!asked.iter().any(|t| dead.outpoint.starts_with(t.as_str())));
10892 assert!(!asked
10893 .iter()
10894 .any(|t| waiting.outpoint.starts_with(t.as_str())));
10895 }
10896
10897 #[tokio::test]
10900 async fn room_0110_a_resume_that_appends_a_node_resets_the_count() {
10901 let UnanchorableFixture {
10902 mut engine,
10903 remote,
10904 store,
10905 root,
10906 ..
10907 } = unanchorable_fixture();
10908 engine.set_graph_budget_limbs(u64::MAX, 1);
10910 engine.set_gasp_remote_factory(Box::new(remote));
10911 let mut record = bare_record(0, 10, "retry", true);
10912 record.outpoint.clone_from(&root);
10913 record.pending[0].outpoint.clone_from(&root);
10914 record.pending[0].graph_id.clone_from(&root);
10915 record.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF - 1;
10916 engine.storage().put_deferred_graph(&record).await.unwrap();
10917 let result = engine.start_gasp_sync().await.unwrap();
10918 let topic = &result.topics_synced["tm_test"];
10919 assert_eq!(topic.resumed_graphs, 1, "{topic:?}");
10920 assert_eq!(topic.backed_off_graphs, 0);
10921 let held = store
10922 .get_deferred_graph("https://budget.example", "tm_test", &root)
10923 .await
10924 .unwrap()
10925 .unwrap();
10926 assert_eq!(held.nodes.len(), 1, "{held:?}");
10927 assert_eq!(held.reason, "nodes");
10928 assert_eq!(held.idle_resumes, 0);
10929 assert_eq!(held.retry_at, 0);
10930 }
10931
10932 struct ExhaustedCourier {
10936 asked: std::rc::Rc<Cell<u64>>,
10937 transient: std::rc::Rc<Cell<bool>>,
10939 }
10940
10941 #[async_trait(?Send)]
10942 impl AncestorFetcher for ExhaustedCourier {
10943 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10944 self.asked.set(self.asked.get() + 1);
10945 if self.transient.get() {
10946 return Err(GASPError::RemoteError(format!(
10947 "rung rate limited for {txid}"
10948 )));
10949 }
10950 Err(GASPError::NodeNotFound(format!(
10951 "no raw tx for {txid} (bitails + woc + bananablocks exhausted)"
10952 )))
10953 }
10954 async fn verified_proof_for(&self, _: &str) -> Option<String> {
10955 None
10956 }
10957 }
10958
10959 struct ObligationFixture {
10965 engine: Engine,
10966 remote: BudgetPageRemote,
10967 store: std::rc::Rc<MemoryStorage>,
10968 obligations: Vec<String>,
10969 courier: std::rc::Rc<Cell<u64>>,
10970 transient: std::rc::Rc<Cell<bool>>,
10971 }
10972
10973 async fn obligation_fixture(page: u32, count: u32, node_cost_ms: u64) -> ObligationFixture {
10974 let (mut engine, mut remote) = budget_page_engine(page);
10975 remote.node_cost_ms = node_cost_ms;
10976 let mut obligations = Vec::new();
10977 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10978 let mut records = Vec::new();
10979 for n in 0..count {
10980 let parent = child_of(&format!("{:064x}", 0xbeef_0000 + n), 0);
10981 let child = child_of(&parent.id(), 0);
10982 remote.faults_for.borrow_mut().insert(parent.id());
10987 nodes.insert(
10988 child.id(),
10989 crate::types::GASPNode {
10990 graph_id: String::new(),
10991 raw_tx: child.to_hex(),
10992 output_index: 0,
10993 proof: None,
10994 tx_metadata: None,
10995 output_metadata: None,
10996 inputs: None,
10997 },
10998 );
10999 let mut record = bare_record(n, 1 + u64::from(n), "retry", true);
11000 let outpoint = format!("{}.0", child.id());
11001 record.outpoint.clone_from(&outpoint);
11002 record.pending[0].outpoint.clone_from(&outpoint);
11003 record.pending[0].graph_id.clone_from(&outpoint);
11004 obligations.push(outpoint);
11005 records.push(record);
11006 }
11007 let courier = std::rc::Rc::new(Cell::new(0));
11008 let transient = std::rc::Rc::new(Cell::new(false));
11009 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
11010 asked: courier.clone(),
11011 transient: transient.clone(),
11012 }));
11013 let store = std::rc::Rc::new(MemoryStorage::new());
11014 engine.storage = Box::new(store.clone());
11015 virtual_peer_budget(&mut engine, &remote);
11016 engine.set_graph_budget(
11017 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
11018 100,
11019 60_000,
11020 );
11021 engine.set_gasp_remote_factory(Box::new(remote.clone()));
11022 for record in &records {
11023 let json = serde_json::to_value(record).unwrap();
11025 assert!(json.get("idleResumes").is_none() && json.get("faults").is_none());
11026 engine.storage().put_deferred_graph(record).await.unwrap();
11027 }
11028 ObligationFixture {
11029 engine,
11030 remote,
11031 store,
11032 obligations,
11033 courier,
11034 transient,
11035 }
11036 }
11037
11038 #[tokio::test]
11047 async fn obligations_0111_a_faulted_resume_is_struck_and_the_page_is_walked() {
11048 let ObligationFixture {
11049 engine,
11050 remote,
11051 store,
11052 obligations,
11053 courier,
11054 ..
11055 } = obligation_fixture(3, 1, 0).await;
11056 let host = "https://budget.example";
11057 let held = &obligations[0];
11058 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
11059 for run in 1..=runs {
11060 let result = engine.start_gasp_sync().await.unwrap();
11061 let topic = &result.topics_synced["tm_test"];
11062 let named = topic
11063 .faulted_roots
11064 .iter()
11065 .find(|f| &f.outpoint == held)
11066 .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11067 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
11068 assert!(named.error.contains("exhausted"), "{named:?}");
11069 assert_eq!(named.faults, run, "run {run}: struck on its own slot");
11070 assert!(named.refused.is_empty(), "{named:?}");
11071 assert_eq!(topic.strikes_refused, 0);
11072 assert!(topic.cursor_held_by.is_empty(), "run {run}: {topic:?}");
11073 if run == 1 {
11074 assert_eq!(topic.finalized_graphs, 3, "the page, the same run");
11075 assert_eq!(topic.cursor_moves[0].to, 30);
11076 }
11077 let record = store
11078 .get_deferred_graph(host, "tm_test", held)
11079 .await
11080 .unwrap()
11081 .unwrap();
11082 assert_eq!(record.faults, run, "{record:?}");
11083 assert!(record.cursor_passable, "run {run}: never holds the cursor");
11084 assert!(record.nodes.is_empty());
11085 if run < runs {
11086 assert!(!named.unanchorable);
11087 assert_eq!(record.reason, "retry:fault:courier");
11088 assert_eq!(topic.unanchorable_graphs, 0);
11089 } else {
11090 assert!(named.unanchorable);
11091 assert_eq!(record.reason, "unanchorable:courier");
11092 assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
11093 assert_eq!(topic.unanchorable_graphs, 1, "{topic:?}");
11094 }
11095 }
11096 let (asked, couriered) = (remote.asked.borrow().len(), courier.get());
11099 let result = engine.start_gasp_sync().await.unwrap();
11100 let topic = &result.topics_synced["tm_test"];
11101 assert!(topic.faulted_roots.is_empty(), "{topic:?}");
11102 assert_eq!(topic.resumed_graphs, 0);
11103 assert_eq!(remote.asked.borrow().len(), asked);
11104 assert_eq!(courier.get(), couriered);
11105 assert!(store.deferred_graphs().is_empty());
11107 assert_eq!(store.fault_memos().len(), 1);
11108 }
11109
11110 #[tokio::test]
11122 async fn obligations_0111_a_resume_that_appends_nothing_counts_on_every_ending() {
11123 let host = "https://budget.example";
11124 let fixture = obligation_fixture(1, 1, 0).await;
11127 fixture.store.set_fault_memo_ceiling(Some(0));
11128 let held = &fixture.obligations[0];
11129 let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
11130 for run in 1..=idle {
11131 let result = fixture.engine.start_gasp_sync().await.unwrap();
11132 let topic = &result.topics_synced["tm_test"];
11133 let record = fixture
11134 .store
11135 .get_deferred_graph(host, "tm_test", held)
11136 .await
11137 .unwrap()
11138 .unwrap();
11139 assert_eq!(record.idle_resumes, run, "run {run}: {record:?} {topic:?}");
11140 assert_eq!(record.reason, "retry", "the drop stands");
11141 assert!(record.cursor_passable && record.nodes.is_empty());
11142 assert_eq!(topic.idle_counted, 1, "run {run}: {topic:?}");
11143 let named = topic
11144 .faulted_roots
11145 .iter()
11146 .find(|f| &f.outpoint == held)
11147 .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11148 assert_eq!(named.refused, crate::gasp::strike_refused::NO_ROOM);
11149 assert_eq!(named.faults, 0);
11150 assert_eq!(topic.strikes_refused, 1);
11151 assert_eq!(topic.backed_off_graphs, u64::from(run == idle));
11152 }
11153 let result = fixture.engine.start_gasp_sync().await.unwrap();
11154 let topic = &result.topics_synced["tm_test"];
11155 assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
11156 assert_eq!(topic.idle_counted, 0);
11157
11158 let mut fixture = obligation_fixture(1, 1, 0).await;
11160 let gone = fixture.obligations[0].clone();
11161 std::rc::Rc::make_mut(&mut fixture.remote.nodes).remove(gone.strip_suffix(".0").unwrap());
11162 fixture
11163 .engine
11164 .set_gasp_remote_factory(Box::new(fixture.remote.clone()));
11165 let result = fixture.engine.start_gasp_sync().await.unwrap();
11166 let topic = &result.topics_synced["tm_test"];
11167 assert_eq!(topic.idle_counted, 1, "{topic:?}");
11168 assert_eq!(
11169 topic
11170 .dropped_graphs
11171 .iter()
11172 .map(|d| d.reason.as_str())
11173 .collect::<Vec<_>>(),
11174 vec!["not_held"]
11175 );
11176 let record = fixture
11177 .store
11178 .get_deferred_graph(host, "tm_test", &gone)
11179 .await
11180 .unwrap()
11181 .unwrap();
11182 assert_eq!((record.idle_resumes, record.faults), (1, 0), "{record:?}");
11183
11184 let fixture = obligation_fixture(1, 1, 0).await;
11188 fixture.transient.set(true);
11189 let kept = &fixture.obligations[0];
11190 let result = fixture.engine.start_gasp_sync().await.unwrap();
11191 assert_eq!(result.topics_synced["tm_test"].idle_counted, 0, "progress");
11192 let result = fixture.engine.start_gasp_sync().await.unwrap();
11193 let topic = &result.topics_synced["tm_test"];
11194 assert_eq!(topic.idle_counted, 1, "{topic:?}");
11195 let record = fixture
11196 .store
11197 .get_deferred_graph(host, "tm_test", kept)
11198 .await
11199 .unwrap()
11200 .unwrap();
11201 assert_eq!(record.reason, "fault");
11202 assert_eq!(
11203 (record.nodes.len(), record.idle_resumes),
11204 (1, 1),
11205 "{record:?}"
11206 );
11207 }
11208
11209 #[tokio::test]
11220 async fn obligations_0111_fourteen_obligations_leave_the_page_its_share() {
11221 let ObligationFixture {
11222 mut engine,
11223 remote,
11224 store,
11225 obligations,
11226 ..
11227 } = obligation_fixture(12, 14, 1500).await;
11228 let elapsed = remote.elapsed.clone();
11229 engine.set_graph_budget(
11230 std::rc::Rc::new(move |ms| {
11231 let elapsed = elapsed.clone();
11232 let until = elapsed.get() + ms;
11233 Box::pin(std::future::poll_fn(move |_| {
11234 if elapsed.get() >= until {
11235 std::task::Poll::Ready(())
11236 } else {
11237 std::task::Poll::Pending
11238 }
11239 }))
11240 }),
11241 100,
11242 15_000,
11243 );
11244 assert_eq!(crate::gasp::resume_share_ms(30_000, 15_000), 9_900);
11245 let result = engine.start_gasp_sync().await.unwrap();
11246 let topic = &result.topics_synced["tm_test"];
11247 assert!(topic.errors.is_empty(), "inside the slice: {topic:?}");
11248 assert_eq!(topic.finalized_graphs, 12, "the page's share: {topic:?}");
11249 assert_eq!(topic.cursor_moves[0].to, 120);
11250 assert_eq!(topic.resumed_graphs, 4, "{topic:?}");
11257 assert_eq!(topic.held_back_graphs, 10, "{topic:?}");
11258 assert_eq!(topic.faulted_roots.len(), 3, "{topic:?}");
11259 assert_eq!((topic.stalled_graphs, topic.idle_counted), (1, 1));
11260 assert!(topic.cursor_held_by.is_empty());
11261 let host = "https://budget.example";
11262 let mut reasons = std::collections::BTreeMap::new();
11263 for outpoint in &obligations {
11264 let record = store
11265 .get_deferred_graph(host, "tm_test", outpoint)
11266 .await
11267 .unwrap()
11268 .unwrap();
11269 assert!(record.cursor_passable, "{record:?}");
11270 if record.reason == "retry" {
11271 assert_eq!((record.idle_resumes, record.retry_at), (0, 0));
11273 assert_eq!((record.faults, record.passes), (0, 0));
11274 }
11275 *reasons.entry(record.reason).or_insert(0) += 1;
11276 }
11277 assert_eq!(
11278 reasons,
11279 std::collections::BTreeMap::from([
11280 ("retry".to_string(), 10),
11281 ("retry:fault:courier".to_string(), 3),
11282 ("time".to_string(), 1),
11283 ])
11284 );
11285 }
11286
11287 #[tokio::test]
11288 async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
11289 let (mut engine, mut remote) = budget_page_engine(1);
11290 let txid = remote.outputs[0].txid.clone();
11291 std::rc::Rc::make_mut(&mut remote.nodes)
11292 .get_mut(&txid)
11293 .unwrap()
11294 .proof = None;
11295 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
11296 proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
11297 requested: std::rc::Rc::default(),
11298 }));
11299 virtual_peer_budget(&mut engine, &remote);
11300 engine.set_gasp_remote_factory(Box::new(remote));
11301 let result = engine.start_gasp_sync().await.unwrap();
11302 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
11303 assert!(engine
11304 .storage
11305 .find_output(&txid, 0, Some("tm_test"), None, true)
11306 .await
11307 .unwrap()
11308 .is_none());
11309 }
11310
11311 impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
11312 fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11313 Box::new(MockSyncRemote {
11314 utxos: vec![
11315 crate::types::GASPOutput {
11316 txid: "remote_tx1".to_string(),
11317 output_index: 0,
11318 score: 100.0,
11319 },
11320 crate::types::GASPOutput {
11321 txid: "remote_tx2".to_string(),
11322 output_index: 0,
11323 score: 200.0,
11324 },
11325 ],
11326 })
11327 }
11328 }
11329
11330 #[tokio::test]
11331 async fn test_start_gasp_sync_with_factory_runs_sync() {
11332 let mut sync_config: SyncConfiguration = HashMap::new();
11333 sync_config.insert(
11334 "tm_test".to_string(),
11335 SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
11336 );
11337
11338 let mut engine = Engine::new(
11339 HashMap::new(),
11340 HashMap::new(),
11341 Box::new(MemoryStorage::new()),
11342 None,
11343 EngineConfig {
11344 sync_configuration: sync_config,
11345 ..Default::default()
11346 },
11347 );
11348
11349 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11350
11351 let result = engine.start_gasp_sync().await.unwrap();
11352 assert_eq!(result.topics_synced.len(), 1);
11353
11354 let topic_result = &result.topics_synced["tm_test"];
11355 assert_eq!(topic_result.peers.len(), 1);
11356 assert!(
11357 topic_result.errors.is_empty(),
11358 "Sync should succeed with mock remote"
11359 );
11360
11361 let last = engine
11363 .storage()
11364 .get_last_interaction("https://peer1.example.com", "tm_test")
11365 .await
11366 .unwrap();
11367 assert_eq!(
11370 last, 0,
11371 "cursor must remain at entry when no output completed"
11372 );
11373 }
11374
11375 #[tokio::test]
11376 async fn test_start_gasp_sync_with_factory_handles_error() {
11377 struct FailingFactory;
11378
11379 impl crate::gasp::GASPRemoteFactory for FailingFactory {
11380 fn create_remote(
11381 &self,
11382 _peer_url: &str,
11383 _topic: &str,
11384 ) -> Box<dyn crate::gasp::GASPRemote> {
11385 struct FailingRemote;
11386 #[async_trait(?Send)]
11387 impl crate::gasp::GASPRemote for FailingRemote {
11388 async fn get_initial_response(
11389 &self,
11390 _: &crate::types::GASPInitialRequest,
11391 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
11392 {
11393 Err(crate::gasp::GASPError::RemoteError(
11394 "connection refused".into(),
11395 ))
11396 }
11397 async fn get_initial_reply(
11398 &self,
11399 _: &crate::types::GASPInitialResponse,
11400 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
11401 {
11402 unreachable!()
11403 }
11404 async fn request_node(
11405 &self,
11406 _: &str,
11407 _: &str,
11408 _: u32,
11409 _: bool,
11410 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
11411 {
11412 unreachable!()
11413 }
11414 async fn submit_node(
11415 &self,
11416 _: &crate::types::GASPNode,
11417 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
11418 {
11419 unreachable!()
11420 }
11421 }
11422 Box::new(FailingRemote)
11423 }
11424 }
11425
11426 let mut sync_config: SyncConfiguration = HashMap::new();
11427 sync_config.insert(
11428 "tm_test".to_string(),
11429 SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
11430 );
11431
11432 let mut engine = Engine::new(
11433 HashMap::new(),
11434 HashMap::new(),
11435 Box::new(MemoryStorage::new()),
11436 None,
11437 EngineConfig {
11438 sync_configuration: sync_config,
11439 ..Default::default()
11440 },
11441 );
11442
11443 engine.set_gasp_remote_factory(Box::new(FailingFactory));
11444
11445 let result = engine.start_gasp_sync().await.unwrap();
11446 assert_eq!(result.topics_synced.len(), 1);
11447
11448 let topic_result = &result.topics_synced["tm_test"];
11449 assert_eq!(
11450 topic_result.errors.len(),
11451 1,
11452 "Should have one error for the failing peer"
11453 );
11454 assert!(topic_result.errors[0].contains("connection refused"));
11455 }
11456
11457 #[tokio::test]
11458 async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
11459 let mut sync_config: SyncConfiguration = HashMap::new();
11460 sync_config.insert(
11461 "tm_test".to_string(),
11462 SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
11463 );
11464
11465 let engine = Engine::new(
11467 HashMap::new(),
11468 HashMap::new(),
11469 Box::new(MemoryStorage::new()),
11470 None,
11471 EngineConfig {
11472 sync_configuration: sync_config,
11473 ..Default::default()
11474 },
11475 );
11476
11477 let result = engine.start_gasp_sync().await.unwrap();
11478 assert_eq!(result.topics_synced.len(), 1);
11479
11480 let topic_result = &result.topics_synced["tm_test"];
11481 assert_eq!(topic_result.peers.len(), 1);
11482 assert!(topic_result.errors.is_empty());
11483 }
11484
11485 #[tokio::test]
11486 async fn test_start_gasp_sync_with_factory_multiple_peers() {
11487 let mut sync_config: SyncConfiguration = HashMap::new();
11488 sync_config.insert(
11489 "tm_test".to_string(),
11490 SyncTarget::Peers(vec![
11491 "https://peer1.example.com".to_string(),
11492 "https://peer2.example.com".to_string(),
11493 ]),
11494 );
11495
11496 let mut engine = Engine::new(
11497 HashMap::new(),
11498 HashMap::new(),
11499 Box::new(MemoryStorage::new()),
11500 None,
11501 EngineConfig {
11502 sync_configuration: sync_config,
11503 ..Default::default()
11504 },
11505 );
11506
11507 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11508
11509 let result = engine.start_gasp_sync().await.unwrap();
11510 let topic_result = &result.topics_synced["tm_test"];
11511 assert_eq!(topic_result.peers.len(), 2);
11512 assert!(topic_result.errors.is_empty());
11513
11514 let last1 = engine
11516 .storage()
11517 .get_last_interaction("https://peer1.example.com", "tm_test")
11518 .await
11519 .unwrap();
11520 let last2 = engine
11521 .storage()
11522 .get_last_interaction("https://peer2.example.com", "tm_test")
11523 .await
11524 .unwrap();
11525 assert_eq!(last1, 0);
11526 assert_eq!(last2, 0);
11527 }
11528
11529 struct HangingRemote;
11535
11536 #[async_trait(?Send)]
11537 impl crate::gasp::GASPRemote for HangingRemote {
11538 async fn get_initial_response(
11539 &self,
11540 _: &crate::types::GASPInitialRequest,
11541 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
11542 std::future::pending::<()>().await;
11543 unreachable!()
11544 }
11545 async fn get_initial_reply(
11546 &self,
11547 _: &crate::types::GASPInitialResponse,
11548 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
11549 unreachable!()
11550 }
11551 async fn request_node(
11552 &self,
11553 _: &str,
11554 _: &str,
11555 _: u32,
11556 _: bool,
11557 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
11558 unreachable!()
11559 }
11560 async fn submit_node(
11561 &self,
11562 _: &crate::types::GASPNode,
11563 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
11564 unreachable!()
11565 }
11566 }
11567
11568 struct SelectiveHangFactory {
11572 created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
11573 }
11574
11575 impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
11576 fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11577 self.created.borrow_mut().push(peer_url.to_string());
11578 if peer_url.contains("hang.example.com") {
11579 Box::new(HangingRemote)
11580 } else {
11581 Box::new(MockSyncRemote {
11582 utxos: vec![crate::types::GASPOutput {
11583 txid: "remote_tx1".to_string(),
11584 output_index: 0,
11585 score: 100.0,
11586 }],
11587 })
11588 }
11589 }
11590 }
11591
11592 fn instant_deadline() -> crate::engine::SleepFactory {
11597 std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
11598 }
11599
11600 fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
11601 let mut sync_config: SyncConfiguration = HashMap::new();
11602 sync_config.insert(
11603 "tm_test".to_string(),
11604 SyncTarget::Peers(vec![
11605 "https://hang.example.com".to_string(),
11606 "https://good.example.com".to_string(),
11607 ]),
11608 );
11609 Engine::new(
11610 HashMap::new(),
11611 HashMap::new(),
11612 Box::new(store),
11613 None,
11614 EngineConfig {
11615 sync_configuration: sync_config,
11616 ..Default::default()
11617 },
11618 )
11619 }
11620
11621 #[tokio::test]
11622 async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
11623 let store = std::rc::Rc::new(MemoryStorage::new());
11624 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11625 let mut engine = two_peer_engine(store.clone());
11626 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11627 created: created.clone(),
11628 }));
11629 engine.set_peer_sync_budget(instant_deadline(), 1);
11630
11631 let result = engine.start_gasp_sync().await.unwrap();
11632 let topic_result = &result.topics_synced["tm_test"];
11633
11634 assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
11637 assert!(
11638 topic_result.errors[0].contains("budget"),
11639 "{:?}",
11640 topic_result.errors
11641 );
11642 assert_eq!(
11643 created.borrow().as_slice(),
11644 ["https://hang.example.com", "https://good.example.com"],
11645 "both peers attempted this tick"
11646 );
11647
11648 let hang_cursor = store
11651 .get_last_interaction("https://hang.example.com", "tm_test")
11652 .await
11653 .unwrap();
11654 assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
11655 let good_cursor = store
11656 .get_last_interaction("https://good.example.com", "tm_test")
11657 .await
11658 .unwrap();
11659 assert_eq!(good_cursor, 0, "no synthetic graph completed");
11660
11661 let hang_health = store
11663 .get_peer_sync_health("hang.example.com", "tm_test")
11664 .await
11665 .unwrap();
11666 assert_eq!(hang_health.consecutive_failures, 1);
11667 let good_health = store
11668 .get_peer_sync_health("good.example.com", "tm_test")
11669 .await
11670 .unwrap();
11671 assert_eq!(good_health.consecutive_failures, 0);
11672 }
11673
11674 #[tokio::test]
11675 async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
11676 let store = std::rc::Rc::new(MemoryStorage::new());
11677 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11678
11679 let mut sync_config: SyncConfiguration = HashMap::new();
11680 sync_config.insert(
11681 "tm_test".to_string(),
11682 SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
11683 );
11684 let mut engine = Engine::new(
11685 HashMap::new(),
11686 HashMap::new(),
11687 Box::new(store.clone()),
11688 None,
11689 EngineConfig {
11690 sync_configuration: sync_config,
11691 ..Default::default()
11692 },
11693 );
11694 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11695 created: created.clone(),
11696 }));
11697 engine.set_peer_sync_budget(instant_deadline(), 1);
11698
11699 for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
11703 engine.start_gasp_sync().await.unwrap();
11704 store.advance_clock(900);
11705 assert_eq!(
11706 created.borrow().len() as u64,
11707 i + 1,
11708 "peer attempted while below the threshold"
11709 );
11710 }
11711 let health = store
11712 .get_peer_sync_health("hang.example.com", "tm_test")
11713 .await
11714 .unwrap();
11715 assert_eq!(
11716 health.consecutive_failures,
11717 crate::gasp::PEER_QUARANTINE_THRESHOLD
11718 );
11719
11720 let result = engine.start_gasp_sync().await.unwrap();
11724 assert!(result.topics_synced["tm_test"].errors.is_empty());
11725 assert_eq!(
11726 created.borrow().len() as u64,
11727 crate::gasp::PEER_QUARANTINE_THRESHOLD,
11728 "quarantined peer must be skipped"
11729 );
11730
11731 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11733 engine.start_gasp_sync().await.unwrap();
11734 assert_eq!(
11735 created.borrow().len() as u64,
11736 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
11737 "re-probe attempted after the window"
11738 );
11739 let health = store
11741 .get_peer_sync_health("hang.example.com", "tm_test")
11742 .await
11743 .unwrap();
11744 assert_eq!(
11745 health.consecutive_failures,
11746 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
11747 );
11748 let result = engine.start_gasp_sync().await.unwrap();
11749 assert!(
11750 result.topics_synced["tm_test"].errors.is_empty(),
11751 "re-quarantined"
11752 );
11753
11754 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11758 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11760 let result = engine.start_gasp_sync().await.unwrap();
11761 assert!(result.topics_synced["tm_test"].errors.is_empty());
11762 let health = store
11763 .get_peer_sync_health("hang.example.com", "tm_test")
11764 .await
11765 .unwrap();
11766 assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
11767 }
11768
11769 use crate::broadcaster::ArcBroadcaster;
11772
11773 struct MockArcBroadcaster {
11774 calls: Arc<Mutex<Vec<String>>>,
11775 should_fail: bool,
11776 }
11777
11778 impl MockArcBroadcaster {
11779 fn new() -> Self {
11780 Self {
11781 calls: Arc::new(Mutex::new(Vec::new())),
11782 should_fail: false,
11783 }
11784 }
11785
11786 fn failing() -> Self {
11787 Self {
11788 calls: Arc::new(Mutex::new(Vec::new())),
11789 should_fail: true,
11790 }
11791 }
11792 }
11793
11794 #[async_trait(?Send)]
11795 impl ArcBroadcaster for MockArcBroadcaster {
11796 async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
11797 self.calls.lock().unwrap().push(raw_tx_hex.to_string());
11798 if self.should_fail {
11799 Err("mock ARC failure".to_string())
11800 } else {
11801 Ok("mock_txid_from_arc".to_string())
11802 }
11803 }
11804 }
11805
11806 fn make_engine_with_arc(
11808 admit_indices: Vec<u32>,
11809 arc: MockArcBroadcaster,
11810 ) -> (Engine, Arc<Mutex<Vec<String>>>) {
11811 let arc_calls = arc.calls.clone();
11812
11813 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11814 managers.insert(
11815 "tm_test".to_string(),
11816 Box::new(MockTopicManager::admitting(admit_indices)),
11817 );
11818
11819 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
11820 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
11821
11822 let storage = Box::new(MemoryStorage::new());
11823
11824 let engine = Engine::with_all(
11825 managers,
11826 lookup_services,
11827 storage,
11828 None,
11829 None,
11830 Some(Box::new(arc)),
11831 None,
11832 EngineConfig::default(),
11833 );
11834
11835 (engine, arc_calls)
11836 }
11837
11838 #[tokio::test]
11841 async fn test_arc_broadcaster_called_on_current_tx() {
11842 let arc = MockArcBroadcaster::new();
11843 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11844
11845 let beef = test_tagged_beef(vec!["tm_test"]);
11846 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11847
11848 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11849
11850 let calls = arc_calls.lock().unwrap();
11851 assert_eq!(
11852 calls.len(),
11853 1,
11854 "ARC broadcaster should be called once for CurrentTx"
11855 );
11856 assert!(
11857 !calls[0].is_empty(),
11858 "ARC should receive non-empty raw tx hex"
11859 );
11860 }
11861
11862 #[tokio::test]
11863 async fn test_arc_broadcaster_not_called_on_historical_tx() {
11864 let arc = MockArcBroadcaster::new();
11865 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11866
11867 let beef = test_tagged_beef(vec!["tm_test"]);
11868 engine
11869 .submit(&beef, SubmitMode::HistoricalTx)
11870 .await
11871 .unwrap();
11872
11873 assert_eq!(
11874 arc_calls.lock().unwrap().len(),
11875 0,
11876 "ARC broadcaster should NOT be called for HistoricalTx"
11877 );
11878 }
11879
11880 #[tokio::test]
11881 async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
11882 let arc = MockArcBroadcaster::new();
11883 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11884
11885 let beef = test_tagged_beef(vec!["tm_test"]);
11886 engine
11887 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
11888 .await
11889 .unwrap();
11890
11891 assert_eq!(
11892 arc_calls.lock().unwrap().len(),
11893 0,
11894 "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
11895 );
11896 }
11897
11898 #[tokio::test]
11899 async fn test_arc_broadcast_failure_does_not_fail_submit() {
11900 let arc = MockArcBroadcaster::failing();
11901 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11902
11903 let beef = test_tagged_beef(vec!["tm_test"]);
11904 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11906
11907 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11908
11909 assert_eq!(
11911 arc_calls.lock().unwrap().len(),
11912 1,
11913 "ARC broadcaster should be attempted even if it will fail"
11914 );
11915 }
11916
11917 #[tokio::test]
11918 async fn test_arc_broadcaster_not_present_still_works() {
11919 let engine = make_engine(vec![0]);
11921
11922 let beef = test_tagged_beef(vec!["tm_test"]);
11923 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11924
11925 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11926 }
11927
11928 struct TrackingAdvertiser {
11932 created: Arc<Mutex<Vec<AdvertisementData>>>,
11933 existing_ship: Vec<Advertisement>,
11934 existing_slap: Vec<Advertisement>,
11935 tag_topics: Vec<String>,
11939 create_fails: bool,
11941 find_fails: bool,
11943 revoke_empty: bool,
11945 }
11946
11947 impl TrackingAdvertiser {
11948 fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
11949 let created = Arc::new(Mutex::new(Vec::new()));
11950 (
11951 Self {
11952 created: created.clone(),
11953 existing_ship: vec![],
11954 existing_slap: vec![],
11955 tag_topics: vec!["tm_test".to_string()],
11956 create_fails: false,
11957 find_fails: false,
11958 revoke_empty: false,
11959 },
11960 created,
11961 )
11962 }
11963 }
11964
11965 use crate::advertiser::{Advertiser, AdvertiserError};
11966
11967 #[async_trait(?Send)]
11968 impl Advertiser for TrackingAdvertiser {
11969 async fn create_advertisements(
11970 &self,
11971 ads: &[AdvertisementData],
11972 ) -> Result<TaggedBEEF, AdvertiserError> {
11973 if self.create_fails {
11974 return Err(AdvertiserError::CreationFailed(
11975 "mock create refused".into(),
11976 ));
11977 }
11978 self.created.lock().unwrap().extend(ads.iter().cloned());
11979 Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
11980 }
11981
11982 async fn find_all_advertisements(
11983 &self,
11984 protocol: Protocol,
11985 ) -> Result<Vec<Advertisement>, AdvertiserError> {
11986 if self.find_fails {
11987 return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
11988 }
11989 match protocol {
11990 Protocol::Ship => Ok(self.existing_ship.clone()),
11991 Protocol::Slap => Ok(self.existing_slap.clone()),
11992 }
11993 }
11994
11995 async fn revoke_advertisements(
11996 &self,
11997 _ads: &[Advertisement],
11998 ) -> Result<TaggedBEEF, AdvertiserError> {
11999 if self.revoke_empty {
12000 return Ok(TaggedBEEF::new(vec![], vec![]));
12001 }
12002 Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
12003 }
12004
12005 fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
12006 None
12007 }
12008 }
12009
12010 #[tokio::test]
12014 async fn test_sync_advertisements_url_validation() {
12015 let (adv, created) = TrackingAdvertiser::new();
12017
12018 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12019 managers.insert(
12020 "tm_test".to_string(),
12021 Box::new(MockTopicManager::admitting(vec![0])),
12022 );
12023
12024 let engine = Engine::new(
12025 managers,
12026 HashMap::new(),
12027 Box::new(MemoryStorage::new()),
12028 Some(Box::new(adv)),
12029 EngineConfig {
12030 hosting_url: Some(String::new()), ..Default::default()
12032 },
12033 );
12034
12035 engine.sync_advertisements().await.unwrap();
12036 assert!(
12037 created.lock().unwrap().is_empty(),
12038 "Empty hosting URL should skip advertisement creation"
12039 );
12040
12041 let (adv2, created2) = TrackingAdvertiser::new();
12043
12044 let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12045 managers2.insert(
12046 "tm_test".to_string(),
12047 Box::new(MockTopicManager::admitting(vec![0])),
12048 );
12049 managers2.insert(
12051 "tm_ship".to_string(),
12052 Box::new(MockTopicManager::admitting(vec![])),
12053 );
12054
12055 let engine2 = Engine::new(
12056 managers2,
12057 HashMap::new(),
12058 Box::new(MemoryStorage::new()),
12059 Some(Box::new(adv2)),
12060 EngineConfig {
12061 hosting_url: Some("https://valid.example.com".to_string()),
12062 suppress_default_sync_advertisements: true,
12063 ..Default::default()
12064 },
12065 );
12066
12067 engine2.sync_advertisements().await.unwrap();
12068 let ads = created2.lock().unwrap();
12069 assert!(
12071 ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
12072 "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
12073 );
12074 assert!(
12075 ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
12076 "tm_test should get a SHIP advertisement"
12077 );
12078 }
12079
12080 fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
12089 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12090 managers.insert(
12091 "tm_test".to_string(),
12092 Box::new(MockTopicManager::admitting(vec![0])),
12093 );
12094 Engine::new(
12095 managers,
12096 HashMap::new(),
12097 Box::new(MemoryStorage::new()),
12098 Some(Box::new(adv)),
12099 EngineConfig {
12100 hosting_url: Some("https://valid.example.com".to_string()),
12101 suppress_default_sync_advertisements: true,
12102 ..Default::default()
12103 },
12104 )
12105 }
12106
12107 #[tokio::test]
12108 async fn sync_report_counts_admitted_outputs_on_success() {
12109 let (adv, _) = TrackingAdvertiser::new();
12110 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12111 assert!(report.ok(), "clean run must report ok: {report:?}");
12112 assert_eq!(report.to_create, 1, "tm_test was missing");
12113 assert_eq!(
12114 report.admitted.get("tm_test"),
12115 Some(&1),
12116 "the STEAK's admitted count must be surfaced: {report:?}"
12117 );
12118 }
12119
12120 #[tokio::test]
12121 async fn sync_report_surfaces_local_submit_failure() {
12122 let (mut adv, _) = TrackingAdvertiser::new();
12126 adv.tag_topics = vec!["tm_unregistered".to_string()];
12127 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12128 assert!(!report.ok(), "a failed local submit must not report ok");
12129 assert_eq!(report.to_create, 1);
12130 let submit_error = report.submit_error.as_deref().unwrap_or_default();
12131 assert!(
12132 submit_error.contains("tm_unregistered"),
12133 "verbatim engine error expected, got: {submit_error}"
12134 );
12135 assert!(report.admitted.is_empty());
12136 }
12137
12138 #[tokio::test]
12139 async fn sync_report_surfaces_create_failure() {
12140 let (mut adv, _) = TrackingAdvertiser::new();
12141 adv.create_fails = true;
12142 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12143 assert!(!report.ok());
12144 assert!(
12145 report
12146 .create_error
12147 .as_deref()
12148 .unwrap_or_default()
12149 .contains("mock create refused"),
12150 "{report:?}"
12151 );
12152 assert!(report.submit_error.is_none(), "create failed before submit");
12153 }
12154
12155 #[tokio::test]
12156 async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
12157 let (mut adv, created) = TrackingAdvertiser::new();
12161 adv.existing_ship = vec![Advertisement {
12162 protocol: Protocol::Ship,
12163 identity_key: "02aa".to_string(),
12164 domain: "https://valid.example.com".to_string(),
12165 topic_or_service: "tm_test".to_string(),
12166 beef: None,
12167 output_index: None,
12168 }];
12169 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12170 assert!(report.ok(), "{report:?}");
12171 assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
12172 assert!(created.lock().unwrap().is_empty());
12173 assert!(
12174 report.effective(),
12175 "converged-noop is the one zero-admit shape that IS success"
12176 );
12177 }
12178
12179 #[tokio::test]
12180 async fn sync_report_zero_admit_is_ok_but_not_effective() {
12181 let (adv, _) = TrackingAdvertiser::new();
12187 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12188 managers.insert(
12189 "tm_test".to_string(),
12190 Box::new(MockTopicManager::admitting(vec![])), );
12192 let engine = Engine::new(
12193 managers,
12194 HashMap::new(),
12195 Box::new(MemoryStorage::new()),
12196 Some(Box::new(adv)),
12197 EngineConfig {
12198 hosting_url: Some("https://valid.example.com".to_string()),
12199 suppress_default_sync_advertisements: true,
12200 ..Default::default()
12201 },
12202 );
12203 let report = engine.sync_advertisements().await.unwrap();
12204 assert!(report.ok(), "no ERROR occurred: {report:?}");
12205 assert_eq!(report.to_create, 1);
12206 assert_eq!(report.admitted_total(), 0, "{report:?}");
12207 assert!(
12208 !report.effective(),
12209 "zero admitted on a non-empty create must not read as success: {report:?}"
12210 );
12211 }
12212
12213 #[tokio::test]
12214 async fn sync_report_partial_admit_is_not_effective() {
12215 let (mut adv, _) = TrackingAdvertiser::new();
12221 adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
12222 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12223 managers.insert(
12224 "tm_test".to_string(),
12225 Box::new(MockTopicManager::admitting(vec![0])),
12226 );
12227 managers.insert(
12228 "tm_refuse".to_string(),
12229 Box::new(MockTopicManager::admitting(vec![])), );
12231 let engine = Engine::new(
12232 managers,
12233 HashMap::new(),
12234 Box::new(MemoryStorage::new()),
12235 Some(Box::new(adv)),
12236 EngineConfig {
12237 hosting_url: Some("https://valid.example.com".to_string()),
12238 suppress_default_sync_advertisements: true,
12239 ..Default::default()
12240 },
12241 );
12242 let report = engine.sync_advertisements().await.unwrap();
12243 assert!(report.ok(), "no ERROR occurred: {report:?}");
12244 assert_eq!(report.to_create, 2, "{report:?}");
12245 assert_eq!(report.admitted_total(), 1, "{report:?}");
12246 assert!(
12247 !report.effective(),
12248 "a partial admit must not read as success: {report:?}"
12249 );
12250 }
12251
12252 #[tokio::test]
12253 async fn sync_report_lookup_failure_refuses_creation() {
12254 let (mut adv, created) = TrackingAdvertiser::new();
12258 adv.find_fails = true;
12259 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12260 assert!(!report.ok(), "{report:?}");
12261 assert!(!report.effective());
12262 assert!(
12263 report
12264 .lookup_error
12265 .as_deref()
12266 .unwrap_or_default()
12267 .contains("mock lookup refused"),
12268 "{report:?}"
12269 );
12270 assert_eq!(report.to_create, 0, "creation refused, not attempted");
12271 assert!(
12272 created.lock().unwrap().is_empty(),
12273 "no create call on a blind read"
12274 );
12275 assert!(report.submit_error.is_none());
12276 }
12277
12278 #[tokio::test]
12279 async fn sync_report_counts_skipped_revocations() {
12280 let (mut adv, _) = TrackingAdvertiser::new();
12283 adv.revoke_empty = true;
12284 adv.existing_ship = vec![
12285 Advertisement {
12286 protocol: Protocol::Ship,
12287 identity_key: "02aa".to_string(),
12288 domain: "https://valid.example.com".to_string(),
12289 topic_or_service: "tm_test".to_string(),
12290 beef: None,
12291 output_index: None,
12292 },
12293 Advertisement {
12294 protocol: Protocol::Ship,
12295 identity_key: "02aa".to_string(),
12296 domain: "https://valid.example.com".to_string(),
12297 topic_or_service: "tm_stale".to_string(), beef: None,
12299 output_index: None,
12300 },
12301 ];
12302 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12303 assert!(report.ok(), "{report:?}");
12304 assert_eq!(report.to_revoke, 1, "tm_stale is stale");
12305 assert_eq!(
12306 report.revoke_skipped, 1,
12307 "the v1 no-op must be visible, not silent: {report:?}"
12308 );
12309 assert!(report.revoke_error.is_none());
12310 assert!(report.revoke_submit_error.is_none());
12311 }
12312
12313 use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
12316 use std::cell::Cell;
12317
12318 const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12320
12321 fn proofless_beef() -> (Vec<u8>, String) {
12323 use bsv_rs::transaction::{Beef, Transaction};
12324 let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
12325 let txid = tx.id();
12326 let mut beef = Beef::new();
12327 beef.merge_transaction(tx);
12328 (beef.to_binary(), txid)
12329 }
12330
12331 fn subject_not_last_beef() -> (Vec<u8>, String, String) {
12341 use bsv_rs::transaction::Beef;
12342 let parsed = Beef::from_binary(&test_beef()).unwrap();
12343 let parent = parsed.txs[0].tx().unwrap().clone();
12344 let child = parsed.txs[1].tx().unwrap().clone();
12345 let (parent_id, child_id) = (parent.id(), child.id());
12346 assert_ne!(parent_id, child_id);
12347
12348 let mut beef = Beef::new();
12354 beef.merge_transaction(child); beef.merge_transaction(parent);
12356 let mut w = bsv_rs::primitives::encoding::Writer::new();
12357 beef.to_writer(&mut w);
12358 let bytes = w.into_bytes();
12359
12360 let reread = Beef::from_binary(&bytes).unwrap();
12363 assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
12364 (bytes, child_id, parent_id)
12365 }
12366
12367 #[test]
12368 fn stitch_preserves_the_subject_when_it_is_not_last() {
12369 use bsv_rs::transaction::{Beef, MerklePath};
12370 let (stored, child_id, parent_id) = subject_not_last_beef();
12371 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
12372
12373 let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
12374 .expect("stitch must succeed on a beef that contains the txid");
12375
12376 let after = Beef::from_binary(&out).unwrap();
12377 assert!(
12378 after.find_txid(&child_id).is_some(),
12379 "the SUBJECT must survive its own proof-completion"
12380 );
12381 assert!(
12382 after.find_txid(&parent_id).is_some(),
12383 "the ancestry must survive too"
12384 );
12385 assert!(
12386 after.find_bump(&child_id).is_some(),
12387 "the stitched bump must prove the subject"
12388 );
12389 assert!(
12390 after.find_bump(&parent_id).is_none(),
12391 "the parent must NOT be claimed by the child's bump"
12392 );
12393 }
12394
12395 #[test]
12400 fn the_old_round_trip_demonstrably_dropped_the_subject() {
12401 use bsv_rs::transaction::{Beef, Transaction};
12402 let (stored, child_id, _parent_id) = subject_not_last_beef();
12403 let tx = Transaction::from_beef(&stored, None).unwrap();
12404 assert_ne!(
12405 tx.id(),
12406 child_id,
12407 "from_beef(None) picks txs.last(), not the subject — the trigger"
12408 );
12409 let rebuilt = tx.to_beef(true).unwrap();
12410 let reparsed = Beef::from_binary(&rebuilt).unwrap();
12411 assert!(
12412 reparsed.find_txid(&child_id).is_none(),
12413 "the rebuild loses the subject — exactly the #284 artifact"
12414 );
12415 }
12416
12417 #[test]
12423 fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
12424 use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
12425 let parent = Transaction::from_hex(RAW_PARENT).unwrap();
12427 let parent_id = parent.id();
12428 let subject = child_of(&parent_id, 0);
12429 let subject_id = subject.id();
12430 let orphan = MerklePath::new_unchecked(
12433 965_771,
12434 vec![vec![
12435 MerklePathLeaf::new_txid(0, subject_id.clone()),
12436 MerklePathLeaf::new(1, "aa".repeat(32)),
12437 ]],
12438 )
12439 .unwrap();
12440 let mut beef = Beef::new();
12441 let bi = beef.merge_bump(orphan.clone());
12442 beef.merge_raw_tx(parent.to_binary(), None);
12443 beef.merge_raw_tx(subject.to_binary(), Some(bi));
12444 let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
12445 let stored = beef.to_binary();
12446 assert_eq!(
12447 Beef::from_binary(&stored)
12448 .unwrap()
12449 .find_bump(&subject_id)
12450 .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
12451 Some(orphan_root.clone())
12452 );
12453 let canonical = MerklePath::new_unchecked(
12455 965_771,
12456 vec![vec![
12457 MerklePathLeaf::new_txid(0, subject_id.clone()),
12458 MerklePathLeaf::new(1, "bb".repeat(32)),
12459 ]],
12460 )
12461 .unwrap();
12462 let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
12463 assert_ne!(
12464 canonical_root, orphan_root,
12465 "the reorg shape: same height, new root"
12466 );
12467 let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
12468 let after = Beef::from_binary(&out).unwrap();
12469 let own = after
12471 .find_txid(&subject_id)
12472 .and_then(BeefTx::bump_index)
12473 .and_then(|bi| after.bumps.get(bi))
12474 .unwrap();
12475 assert_eq!(
12476 own.compute_root(Some(&subject_id)).unwrap(),
12477 canonical_root,
12478 "the subject re-anchored to the new bump"
12479 );
12480 assert_eq!(
12482 after.bumps.len(),
12483 1,
12484 "the unreferenced orphan bump was dropped"
12485 );
12486 assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
12488 }
12489
12490 #[test]
12491 fn stitch_refuses_a_beef_that_lacks_the_txid() {
12492 use bsv_rs::transaction::MerklePath;
12493 let (stored, _c, _p) = subject_not_last_beef();
12494 let foreign = "aa".repeat(32);
12495 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
12496 assert!(
12497 Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
12498 "no txid in the beef -> no write, never garbage"
12499 );
12500 }
12501
12502 const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12504
12505 fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
12507 let mut sx = String::from("0100000001");
12508 let mut prev = hex::decode(source_txid).unwrap();
12509 prev.reverse();
12510 sx.push_str(&hex::encode(prev));
12511 sx.push_str(&hex::encode(vout.to_le_bytes()));
12512 sx.push_str("00ffffffff01");
12513 sx.push_str(&hex::encode(1000u64.to_le_bytes()));
12514 sx.push_str("015100000000");
12515 bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
12516 }
12517
12518 fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
12520 use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
12521 let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
12522 MerklePath::new_unchecked(height, vec![vec![leaf]])
12523 .unwrap()
12524 .to_hex()
12525 }
12526
12527 struct StubFetcher {
12530 proof: Option<String>,
12531 calls: Cell<u32>,
12532 }
12533
12534 #[async_trait(?Send)]
12535 impl AncestorFetcher for StubFetcher {
12536 async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
12537 self.calls.set(self.calls.get() + 1);
12538 Ok(FetchedAncestor {
12539 raw_tx: PROOFLESS_RAW_TX.to_string(),
12540 proof: self.proof.clone(),
12541 })
12542 }
12543
12544 async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
12550 true
12551 }
12552 }
12553
12554 async fn engine_with_proofless_output(
12557 fetcher: Option<std::rc::Rc<StubFetcher>>,
12558 ) -> (Engine, String) {
12559 let (beef, txid) = proofless_beef();
12560 let storage = MemoryStorage::new();
12561 storage
12562 .insert_output(&Output {
12563 txid: txid.clone(),
12564 output_index: 0,
12565 output_script: vec![0x76],
12566 satoshis: 1_000_000_000,
12567 topic: "Hello".to_string(),
12568 spent: false,
12569 outputs_consumed: vec![],
12570 consumed_by: vec![],
12571 beef: Some(beef),
12572 block_height: None,
12573 score: Some(1.0),
12574 })
12575 .await
12576 .unwrap();
12577
12578 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12579 managers.insert(
12580 "Hello".into(),
12581 Box::new(MockTopicManager::admitting(vec![0])),
12582 );
12583 let mut engine = Engine::new(
12584 managers,
12585 HashMap::new(),
12586 Box::new(storage),
12587 None,
12588 EngineConfig::default(),
12589 );
12590 if let Some(f) = fetcher {
12591 engine.set_ancestor_fetcher(f);
12592 }
12593 (engine, txid)
12594 }
12595
12596 #[tokio::test]
12597 async fn proofs_completion_uses_dedicated_courier() {
12598 let (_, txid) = proofless_beef();
12599 let shared = std::rc::Rc::new(StubFetcher {
12600 proof: None,
12601 calls: Cell::new(0),
12602 });
12603 let dedicated = StubFetcher {
12604 proof: Some(single_leaf_bump_hex(&txid, 850_000)),
12605 calls: Cell::new(0),
12606 };
12607 let (engine, _) = engine_with_proofless_output(Some(shared.clone())).await;
12608 assert_eq!(
12609 engine
12610 .complete_missing_proofs(1, 0)
12611 .await
12612 .unwrap()
12613 .completed,
12614 0
12615 );
12616 assert_eq!(
12617 engine
12618 .complete_missing_proofs_with(&dedicated, 1, 0)
12619 .await
12620 .unwrap()
12621 .completed,
12622 1
12623 );
12624 assert_eq!(shared.calls.get(), 1);
12625 assert_eq!(dedicated.calls.get(), 1);
12626 let output = engine
12627 .storage()
12628 .find_output(&txid, 0, Some("Hello"), None, true)
12629 .await
12630 .unwrap()
12631 .unwrap();
12632 assert!(
12633 crate::beef_limits::own_proof(output.beef.as_ref().unwrap(), &txid)
12634 .unwrap()
12635 .is_some()
12636 );
12637 }
12638
12639 #[tokio::test]
12640 async fn complete_missing_proofs_noop_without_fetcher() {
12641 let (engine, _txid) = engine_with_proofless_output(None).await;
12643 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12644 assert_eq!(summary, ProofCompletionSummary::default());
12645 }
12646
12647 #[tokio::test]
12648 async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
12649 let (_beef, txid) = proofless_beef();
12650 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12651 let fetcher = std::rc::Rc::new(StubFetcher {
12652 proof: Some(proof_hex),
12653 calls: Cell::new(0),
12654 });
12655 let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12656
12657 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12658 assert_eq!(summary.scanned, 1);
12659 assert_eq!(summary.proofless, 1);
12660 assert_eq!(
12661 summary.completed, 1,
12662 "the proofless BEEF should be completed"
12663 );
12664 assert_eq!(fetcher.calls.get(), 1);
12665
12666 let out = engine
12669 .storage()
12670 .find_output(&txid, 0, Some("Hello"), None, true)
12671 .await
12672 .unwrap()
12673 .unwrap();
12674 assert_eq!(out.block_height, Some(850_000));
12675 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
12676 assert!(
12677 beef.find_txid(&txid)
12678 .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
12679 "completed BEEF must prove the target tx"
12680 );
12681
12682 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
12685 assert_eq!(summary2.proofless, 0);
12686 assert_eq!(summary2.completed, 0);
12687 assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
12688 }
12689
12690 #[tokio::test]
12691 async fn complete_missing_proofs_skips_unconfirmed() {
12692 let fetcher = std::rc::Rc::new(StubFetcher {
12695 proof: None,
12696 calls: Cell::new(0),
12697 });
12698 let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12699 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12700 assert_eq!(summary.proofless, 1);
12701 assert_eq!(summary.completed, 0);
12702 assert_eq!(summary.still_unconfirmed, 1);
12703 assert_eq!(fetcher.calls.get(), 1);
12704 }
12705
12706 async fn engine_with_proofless_output_shared(
12711 fetcher: Option<std::rc::Rc<StubFetcher>>,
12712 ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
12713 let (beef, txid) = proofless_beef();
12714 let storage = std::rc::Rc::new(MemoryStorage::new());
12715 storage
12716 .insert_output(&Output {
12717 txid: txid.clone(),
12718 output_index: 0,
12719 output_script: vec![0x76],
12720 satoshis: 1_000_000_000,
12721 topic: "Hello".to_string(),
12722 spent: false,
12723 outputs_consumed: vec![],
12724 consumed_by: vec![],
12725 beef: Some(beef),
12726 block_height: None,
12727 score: Some(1.0),
12728 })
12729 .await
12730 .unwrap();
12731
12732 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12733 managers.insert(
12734 "Hello".into(),
12735 Box::new(MockTopicManager::admitting(vec![0])),
12736 );
12737 let mut engine = Engine::new(
12738 managers,
12739 HashMap::new(),
12740 Box::new(storage.clone()),
12741 None,
12742 EngineConfig::default(),
12743 );
12744 if let Some(f) = fetcher {
12745 engine.set_ancestor_fetcher(f);
12746 }
12747 (engine, txid, storage)
12748 }
12749
12750 #[tokio::test]
12751 async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
12752 let proof_hex = {
12756 let (_beef, txid) = proofless_beef();
12757 single_leaf_bump_hex(&txid, 850_000)
12758 };
12759 let fetcher = std::rc::Rc::new(StubFetcher {
12760 proof: Some(proof_hex),
12761 calls: Cell::new(0),
12762 });
12763 let (engine, _txid, storage) =
12764 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12765
12766 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12767 assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
12768 assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
12769
12770 storage.advance_clock(1800);
12775 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12776 assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
12777 assert_eq!(summary.completed, 1, "the backstop completes the proof");
12778 assert_eq!(fetcher.calls.get(), 1);
12779 }
12780
12781 #[tokio::test]
12782 async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
12783 let fetcher = std::rc::Rc::new(StubFetcher {
12789 proof: None,
12790 calls: Cell::new(0),
12791 });
12792 let (engine, txid, storage) =
12793 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12794
12795 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12798 engine
12799 .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
12800 .await
12801 .unwrap();
12802
12803 storage.advance_clock(1_000_000);
12806 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12807 assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
12808 assert_eq!(summary.completed, 0);
12809 assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
12810 }
12811
12812 #[tokio::test]
12813 async fn complete_missing_proofs_respects_limit() {
12814 let (beef1, txid1) = proofless_beef();
12816 let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
12818 let (beef2, txid2) = {
12819 use bsv_rs::transaction::{Beef, Transaction};
12820 let tx = Transaction::from_hex(raw2).unwrap();
12821 let id = tx.id();
12822 let mut b = Beef::new();
12823 b.merge_transaction(tx);
12824 (b.to_binary(), id)
12825 };
12826
12827 let storage = MemoryStorage::new();
12828 for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
12829 storage
12830 .insert_output(&Output {
12831 txid: txid.clone(),
12832 output_index: 0,
12833 output_script: vec![0x76],
12834 satoshis: 1000,
12835 topic: "Hello".to_string(),
12836 spent: false,
12837 outputs_consumed: vec![],
12838 consumed_by: vec![],
12839 beef: Some(beef),
12840 block_height: None,
12841 score: Some(1.0),
12842 })
12843 .await
12844 .unwrap();
12845 }
12846
12847 let fetcher = std::rc::Rc::new(StubFetcher {
12848 proof: None,
12849 calls: Cell::new(0),
12850 });
12851 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12852 managers.insert(
12853 "Hello".into(),
12854 Box::new(MockTopicManager::admitting(vec![0])),
12855 );
12856 let mut engine = Engine::new(
12857 managers,
12858 HashMap::new(),
12859 Box::new(storage),
12860 None,
12861 EngineConfig::default(),
12862 );
12863 engine.set_ancestor_fetcher(fetcher.clone());
12864
12865 let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
12866 assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
12867 }
12868
12869 #[tokio::test]
12870 async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
12871 use bsv_rs::transaction::{Beef, MerklePath, Transaction};
12880
12881 let storage = MemoryStorage::new();
12882
12883 let proven_raws = [
12888 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
12889 "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
12890 ];
12891 let mut proven_ids = Vec::new();
12892 for raw in proven_raws {
12893 let mut tx = Transaction::from_hex(raw).unwrap();
12894 let id = tx.id();
12895 tx.merkle_path =
12896 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
12897 let proven_beef = tx.to_beef(true).unwrap();
12900 assert_eq!(
12901 Beef::from_binary(&proven_beef)
12902 .unwrap()
12903 .find_txid(&id)
12904 .map(bsv_rs::transaction::BeefTx::has_proof),
12905 Some(true),
12906 "proven fixture row must carry its proof"
12907 );
12908 proven_ids.push(id.clone());
12909 storage
12910 .insert_output(&Output {
12911 txid: id,
12912 output_index: 0,
12913 output_script: vec![0x76],
12914 satoshis: 1000,
12915 topic: "Hello".to_string(),
12916 spent: false,
12917 outputs_consumed: vec![],
12918 consumed_by: vec![],
12919 beef: Some(proven_beef),
12920 block_height: Some(800_000),
12921 score: Some(1.0),
12922 })
12923 .await
12924 .unwrap();
12925 }
12926
12927 let fetcher = std::rc::Rc::new(StubFetcher {
12928 proof: None,
12929 calls: Cell::new(0),
12930 });
12931 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12932 managers.insert(
12933 "Hello".into(),
12934 Box::new(MockTopicManager::admitting(vec![0])),
12935 );
12936 let mut engine = Engine::new(
12937 managers,
12938 HashMap::new(),
12939 Box::new(storage),
12940 None,
12941 EngineConfig::default(),
12942 );
12943 engine.set_ancestor_fetcher(fetcher.clone());
12944
12945 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12948 assert_eq!(summary.scanned, proven_ids.len());
12949 assert_eq!(summary.proofless, 0);
12950 assert_eq!(summary.completed, 0);
12951 assert_eq!(
12952 summary.already_proven,
12953 proven_ids.len(),
12954 "every already-proven row is marked this pass"
12955 );
12956 assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
12957
12958 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
12961 assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
12962 assert_eq!(summary2.already_proven, 0);
12963 }
12964
12965 #[tokio::test]
12966 async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
12967 use bsv_rs::transaction::{MerklePath, Transaction};
12973
12974 let storage = MemoryStorage::new();
12975
12976 let proven_raws = [
12977 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
12978 ];
12979 for raw in proven_raws {
12980 let mut tx = Transaction::from_hex(raw).unwrap();
12981 let id = tx.id();
12982 tx.merkle_path =
12983 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
12984 let proven_beef = tx.to_beef(true).unwrap();
12985 storage
12986 .insert_output(&Output {
12987 txid: id,
12988 output_index: 0,
12989 output_script: vec![0x76],
12990 satoshis: 1000,
12991 topic: "Hello".to_string(),
12992 spent: false,
12993 outputs_consumed: vec![],
12994 consumed_by: vec![],
12995 beef: Some(proven_beef),
12996 block_height: Some(800_000),
12997 score: Some(1.0),
12998 })
12999 .await
13000 .unwrap();
13001 }
13002
13003 let (proofless, proofless_txid) = proofless_beef();
13005 storage
13006 .insert_output(&Output {
13007 txid: proofless_txid.clone(),
13008 output_index: 0,
13009 output_script: vec![0x76],
13010 satoshis: 1000,
13011 topic: "Hello".to_string(),
13012 spent: false,
13013 outputs_consumed: vec![],
13014 consumed_by: vec![],
13015 beef: Some(proofless),
13016 block_height: None,
13017 score: Some(1.0),
13018 })
13019 .await
13020 .unwrap();
13021
13022 let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
13023 let fetcher = std::rc::Rc::new(StubFetcher {
13024 proof: Some(proof_hex),
13025 calls: Cell::new(0),
13026 });
13027 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
13028 managers.insert(
13029 "Hello".into(),
13030 Box::new(MockTopicManager::admitting(vec![0])),
13031 );
13032 let mut engine = Engine::new(
13033 managers,
13034 HashMap::new(),
13035 Box::new(storage),
13036 None,
13037 EngineConfig::default(),
13038 );
13039 engine.set_ancestor_fetcher(fetcher.clone());
13040
13041 let mut completed_total = 0;
13044 for _ in 0..5 {
13045 let s = engine.complete_missing_proofs(1, 0).await.unwrap();
13046 completed_total += s.completed;
13047 if completed_total > 0 {
13048 break;
13049 }
13050 }
13051 assert_eq!(
13052 completed_total, 1,
13053 "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
13054 );
13055
13056 let out = engine
13057 .storage()
13058 .find_output(&proofless_txid, 0, Some("Hello"), None, true)
13059 .await
13060 .unwrap()
13061 .unwrap();
13062 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
13063 assert_eq!(
13064 beef.find_txid(&proofless_txid)
13065 .map(bsv_rs::transaction::BeefTx::has_proof),
13066 Some(true),
13067 "the old proofless tx now carries its proof"
13068 );
13069 }
13070
13071 use crate::storage::StorageError;
13079 use std::rc::Rc;
13080
13081 struct FaultingStorage {
13086 inner: MemoryStorage,
13087 fail_insert_output: Cell<bool>,
13088 fail_find_output: Cell<bool>,
13089 }
13090
13091 impl FaultingStorage {
13092 fn new() -> Rc<Self> {
13093 Rc::new(Self {
13094 inner: MemoryStorage::new(),
13095 fail_insert_output: Cell::new(false),
13096 fail_find_output: Cell::new(false),
13097 })
13098 }
13099 }
13100
13101 #[async_trait(?Send)]
13102 impl Storage for FaultingStorage {
13103 async fn insert_output_with_origin(
13104 &self,
13105 output: &Output,
13106 gasp: bool,
13107 ) -> Result<(), StorageError> {
13108 if self.fail_insert_output.get() {
13109 return Err(StorageError::Database(
13110 "D1_ERROR: storage overloaded".into(),
13111 ));
13112 }
13113 self.inner.insert_output_with_origin(output, gasp).await
13114 }
13115 async fn read_fault_memos(
13116 &self,
13117 host: &str,
13118 topic: &str,
13119 outpoints: Option<&[String]>,
13120 ) -> Result<crate::fault_memo::FaultMemoBatch, StorageError> {
13121 self.inner.read_fault_memos(host, topic, outpoints).await
13122 }
13123
13124 async fn put_deferred_graph(
13125 &self,
13126 record: &crate::gasp::DeferredGraph,
13127 ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
13128 self.inner.put_deferred_graph(record).await
13129 }
13130 async fn reserve_unanchorable_retry(
13131 &self,
13132 record: &crate::gasp::DeferredGraph,
13133 ) -> Result<bool, StorageError> {
13134 self.inner.reserve_unanchorable_retry(record).await
13135 }
13136 async fn find_deferred_graphs(
13137 &self,
13138 host: &str,
13139 topic: &str,
13140 ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
13141 self.inner.find_deferred_graphs(host, topic).await
13142 }
13143 async fn get_deferred_graph(
13144 &self,
13145 host: &str,
13146 topic: &str,
13147 outpoint: &str,
13148 ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
13149 self.inner.get_deferred_graph(host, topic, outpoint).await
13150 }
13151 async fn delete_deferred_graph(
13152 &self,
13153 host: &str,
13154 topic: &str,
13155 outpoint: &str,
13156 ) -> Result<(), StorageError> {
13157 self.inner
13158 .delete_deferred_graph(host, topic, outpoint)
13159 .await
13160 }
13161 async fn record_peer_sync_outcome(
13162 &self,
13163 host: &str,
13164 topic: &str,
13165 success: bool,
13166 ) -> Result<(), StorageError> {
13167 self.inner
13168 .record_peer_sync_outcome(host, topic, success)
13169 .await
13170 }
13171 async fn get_peer_sync_health(
13172 &self,
13173 host: &str,
13174 topic: &str,
13175 ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
13176 self.inner.get_peer_sync_health(host, topic).await
13177 }
13178 async fn record_peer_sync_yield(
13179 &self,
13180 host: &str,
13181 topic: &str,
13182 yielded: bool,
13183 ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
13184 self.inner
13185 .record_peer_sync_yield(host, topic, yielded)
13186 .await
13187 }
13188 async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
13189 self.insert_output_with_origin(output, false).await
13190 }
13191 async fn delete_output(
13192 &self,
13193 txid: &str,
13194 output_index: u32,
13195 topic: &str,
13196 ) -> Result<(), StorageError> {
13197 self.inner.delete_output(txid, output_index, topic).await
13198 }
13199 async fn mark_utxo_as_spent(
13200 &self,
13201 txid: &str,
13202 output_index: u32,
13203 topic: &str,
13204 ) -> Result<(), StorageError> {
13205 self.inner
13206 .mark_utxo_as_spent(txid, output_index, topic)
13207 .await
13208 }
13209 async fn update_consumed_by(
13210 &self,
13211 txid: &str,
13212 output_index: u32,
13213 topic: &str,
13214 consumed_by: &[Outpoint],
13215 ) -> Result<(), StorageError> {
13216 self.inner
13217 .update_consumed_by(txid, output_index, topic, consumed_by)
13218 .await
13219 }
13220 async fn update_transaction_beef(
13221 &self,
13222 txid: &str,
13223 beef: &[u8],
13224 ) -> Result<(), StorageError> {
13225 self.inner.update_transaction_beef(txid, beef).await
13226 }
13227 async fn insert_applied_transaction(
13228 &self,
13229 tx: &AppliedTransaction,
13230 ) -> Result<(), StorageError> {
13231 self.inner.insert_applied_transaction(tx).await
13232 }
13233 async fn does_applied_transaction_exist(
13234 &self,
13235 tx: &AppliedTransaction,
13236 ) -> Result<bool, StorageError> {
13237 self.inner.does_applied_transaction_exist(tx).await
13238 }
13239 async fn delete_applied_transaction(
13240 &self,
13241 tx: &AppliedTransaction,
13242 ) -> Result<(), StorageError> {
13243 self.inner.delete_applied_transaction(tx).await
13244 }
13245 async fn find_output(
13246 &self,
13247 txid: &str,
13248 output_index: u32,
13249 topic: Option<&str>,
13250 spent: Option<bool>,
13251 include_beef: bool,
13252 ) -> Result<Option<Output>, StorageError> {
13253 if self.fail_find_output.get() {
13254 return Err(StorageError::Database("D1_ERROR: read timed out".into()));
13255 }
13256 self.inner
13257 .find_output(txid, output_index, topic, spent, include_beef)
13258 .await
13259 }
13260 async fn find_outputs_for_transaction(
13261 &self,
13262 txid: &str,
13263 include_beef: bool,
13264 ) -> Result<Vec<Output>, StorageError> {
13265 self.inner
13266 .find_outputs_for_transaction(txid, include_beef)
13267 .await
13268 }
13269 async fn find_utxos_for_topic(
13270 &self,
13271 topic: &str,
13272 since: Option<f64>,
13273 limit: Option<u64>,
13274 include_beef: bool,
13275 ) -> Result<Vec<Output>, StorageError> {
13276 self.inner
13277 .find_utxos_for_topic(topic, since, limit, include_beef)
13278 .await
13279 }
13280 async fn update_last_interaction(
13281 &self,
13282 host: &str,
13283 topic: &str,
13284 since: u64,
13285 ) -> Result<(), StorageError> {
13286 self.inner.update_last_interaction(host, topic, since).await
13287 }
13288 async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
13289 self.inner.get_last_interaction(host, topic).await
13290 }
13291 }
13292
13293 fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
13294 let storage = FaultingStorage::new();
13295 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13296 managers.insert(
13297 "tm_test".to_string(),
13298 Box::new(MockTopicManager::admitting(admit_indices)),
13299 );
13300 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13301 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
13302 let engine = Engine::new(
13303 managers,
13304 lookup_services,
13305 Box::new(Rc::clone(&storage)),
13306 None,
13307 EngineConfig::default(),
13308 );
13309 (engine, storage)
13310 }
13311
13312 async fn applied(storage: &FaultingStorage) -> bool {
13313 storage
13314 .does_applied_transaction_exist(&AppliedTransaction {
13315 txid: TEST_TXID.to_string(),
13316 topic: "tm_test".to_string(),
13317 })
13318 .await
13319 .unwrap()
13320 }
13321
13322 #[tokio::test]
13325 async fn clean_submit_is_durable_and_records_applied() {
13326 let (engine, storage) = make_faulting_engine(vec![0]);
13327 let (steak, report) = engine
13328 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13329 .await
13330 .unwrap();
13331 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
13332 assert!(report.is_durable(), "{}", report.summary());
13333 assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
13334 assert!(applied(&storage).await);
13335 assert!(storage
13336 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13337 .await
13338 .unwrap()
13339 .is_some());
13340 }
13341
13342 #[tokio::test]
13347 async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
13348 let (engine, storage) = make_faulting_engine(vec![0]);
13349 storage.fail_insert_output.set(true);
13350 let (steak, report) = engine
13351 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13352 .await
13353 .unwrap();
13354 assert_eq!(
13355 steak["tm_test"].outputs_to_admit,
13356 vec![0],
13357 "the admission DECISION is unchanged by a write fault"
13358 );
13359 assert!(!report.is_durable());
13360 assert_eq!(report.faults.len(), 1, "{}", report.summary());
13361 assert_eq!(report.faults[0].topic, "tm_test");
13362 assert_eq!(report.faults[0].site, "insert_output");
13363 assert!(report.faults[0].error.contains("D1_ERROR"));
13364 assert!(report.applied_topics.is_empty());
13365 assert!(
13366 !applied(&storage).await,
13367 "a faulted topic must not be recorded as applied — the replay would be a dupe"
13368 );
13369 assert!(storage
13370 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13371 .await
13372 .unwrap()
13373 .is_none());
13374 }
13375
13376 #[tokio::test]
13380 async fn replay_after_a_fault_reapplies_and_records_applied() {
13381 let (engine, storage) = make_faulting_engine(vec![0]);
13382 storage.fail_insert_output.set(true);
13383 let (_, first) = engine
13384 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13385 .await
13386 .unwrap();
13387 assert!(!first.is_durable());
13388
13389 storage.fail_insert_output.set(false);
13390 let (steak, replay) = engine
13391 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13392 .await
13393 .unwrap();
13394 assert_eq!(
13395 steak["tm_test"].outputs_to_admit,
13396 vec![0],
13397 "re-validated, not deduped"
13398 );
13399 assert!(replay.is_durable(), "{}", replay.summary());
13400 assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
13401 assert!(applied(&storage).await);
13402 assert!(storage
13403 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13404 .await
13405 .unwrap()
13406 .is_some());
13407
13408 let (steak, third) = engine
13409 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13410 .await
13411 .unwrap();
13412 assert!(
13413 steak["tm_test"].outputs_to_admit.is_empty(),
13414 "now a genuine dupe: nothing admitted"
13415 );
13416 assert!(third.is_durable() && third.applied_topics.is_empty());
13417 }
13418
13419 #[tokio::test]
13425 async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
13426 let (engine, storage) = make_faulting_engine(vec![0]);
13427 let tx = Transaction::from_beef(&test_beef(), None).unwrap();
13430 let source_txid = tx.inputs[0].get_source_txid().unwrap();
13431 let source_vout = tx.inputs[0].source_output_index;
13432 storage
13433 .insert_output(&Output {
13434 txid: source_txid.clone(),
13435 output_index: source_vout,
13436 output_script: vec![0x51],
13437 satoshis: 1,
13438 topic: "tm_test".to_string(),
13439 spent: false,
13440 outputs_consumed: vec![],
13441 consumed_by: vec![],
13442 beef: None,
13443 block_height: None,
13444 score: Some(1.0),
13445 })
13446 .await
13447 .unwrap();
13448
13449 storage.fail_find_output.set(true);
13450 let (_, faulted) = engine
13451 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13452 .await
13453 .unwrap();
13454 assert!(!faulted.is_durable());
13455 assert!(
13456 faulted.faults.iter().any(|f| f.site == "find_output"),
13457 "{}",
13458 faulted.summary()
13459 );
13460 assert!(
13461 !applied(&storage).await,
13462 "unrecorded: the replay must re-read"
13463 );
13464 storage.fail_find_output.set(false);
13465 assert!(
13466 storage
13467 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13468 .await
13469 .unwrap()
13470 .is_some(),
13471 "the coin the faulted pass could not see is still there, untouched"
13472 );
13473
13474 let (_, replay) = engine
13475 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13476 .await
13477 .unwrap();
13478 assert!(replay.is_durable(), "{}", replay.summary());
13479 assert!(applied(&storage).await);
13480 assert!(
13483 storage
13484 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13485 .await
13486 .unwrap()
13487 .is_none(),
13488 "the healthy replay consumed the coin the faulted pass missed"
13489 );
13490 }
13491
13492 #[tokio::test]
13499 async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
13500 use std::cell::RefCell;
13501 use std::rc::Rc;
13502
13503 struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
13504 #[async_trait(?Send)]
13505 impl LookupServiceTrait for CountingLs {
13506 fn admission_mode(&self) -> AdmissionMode {
13507 self.1
13508 }
13509 fn spend_notification_mode(&self) -> SpendNotificationMode {
13510 SpendNotificationMode::None
13511 }
13512 async fn output_admitted_by_topic(
13513 &self,
13514 payload: &OutputAdmittedByTopic,
13515 ) -> Result<(), LookupServiceError> {
13516 let rec = match payload {
13517 OutputAdmittedByTopic::LockingScript {
13518 txid,
13519 output_index,
13520 topic,
13521 ..
13522 } => (txid.clone(), *output_index, topic.clone()),
13523 OutputAdmittedByTopic::WholeTx {
13524 atomic_beef,
13525 output_index,
13526 topic,
13527 ..
13528 } => {
13529 let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
13530 (
13531 beef.atomic_txid
13532 .clone()
13533 .expect("the WholeTx body NAMES its subject"),
13534 *output_index,
13535 topic.clone(),
13536 )
13537 }
13538 };
13539 self.0.borrow_mut().push(rec);
13540 Ok(())
13541 }
13542 async fn output_evicted(
13543 &self,
13544 _txid: &str,
13545 _output_index: u32,
13546 ) -> Result<(), LookupServiceError> {
13547 Ok(())
13548 }
13549 async fn lookup(
13550 &self,
13551 _question: &LookupQuestion,
13552 ) -> Result<LookupResult, LookupServiceError> {
13553 Ok(LookupResult::OutputList(Vec::new()))
13554 }
13555 async fn get_documentation(&self) -> String {
13556 "counting".to_string()
13557 }
13558 async fn get_metadata(&self) -> ServiceMetadata {
13559 ServiceMetadata {
13560 name: "counting-ls".to_string(),
13561 ..Default::default()
13562 }
13563 }
13564 }
13565
13566 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13567 managers.insert(
13568 "tm_test".to_string(),
13569 Box::new(MockTopicManager::admitting(vec![0])),
13570 );
13571 let seen = Rc::new(RefCell::new(Vec::new()));
13572 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13573 lookup_services.insert(
13574 "ls_whole".to_string(),
13575 Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
13576 );
13577 lookup_services.insert(
13578 "ls_script".to_string(),
13579 Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
13580 );
13581 let engine = Engine::new(
13582 managers,
13583 lookup_services,
13584 Box::new(MemoryStorage::new()),
13585 None,
13586 EngineConfig::default(),
13587 );
13588
13589 let tagged = test_tagged_beef(vec!["tm_test"]);
13590 engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13591 assert_eq!(seen.borrow().len(), 2, "one admit per service");
13592 let (txid, vout, _) = seen.borrow()[0].clone();
13593 assert_eq!(txid, TEST_TXID);
13594
13595 let (_, report) = engine
13597 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13598 .await
13599 .unwrap();
13600 assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
13601 assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
13602
13603 let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
13605 assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
13606 assert_eq!(r.vouts, vec![vout]);
13607 assert_eq!(seen.borrow().len(), 4);
13608 assert!(seen.borrow()[2..]
13609 .iter()
13610 .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
13611
13612 let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
13614 assert_eq!((r0.outputs, r0.notified), (0, 0));
13615 assert_eq!(seen.borrow().len(), 4);
13616
13617 let r2 = engine
13619 .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
13620 .await
13621 .unwrap();
13622 assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
13623 assert_eq!(seen.borrow().len(), 6);
13624 }
13625
13626 #[tokio::test]
13631 async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
13632 let engine = make_engine(vec![]);
13634 let tagged = test_tagged_beef(vec!["tm_test"]);
13635 let (steak, r1) = engine
13636 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13637 .await
13638 .unwrap();
13639 assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
13640 assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
13641 let (_, r2) = engine
13642 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13643 .await
13644 .unwrap();
13645 assert_eq!(
13646 r2.deduped_topics,
13647 vec!["tm_test".to_string()],
13648 "the phantom row dedups the re-submit"
13649 );
13650 assert!(engine
13651 .forget_phantom_applied(TEST_TXID, "tm_test")
13652 .await
13653 .unwrap());
13654 let (_, r3) = engine
13655 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13656 .await
13657 .unwrap();
13658 assert!(
13659 r3.deduped_topics.is_empty(),
13660 "judged again after the forget"
13661 );
13662 assert!(
13663 !engine
13664 .forget_phantom_applied(TEST_TXID, "tm_other")
13665 .await
13666 .unwrap(),
13667 "no row: nothing to forget"
13668 );
13669
13670 let real = make_engine(vec![0]);
13672 real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13673 assert!(!real
13674 .forget_phantom_applied(TEST_TXID, "tm_test")
13675 .await
13676 .unwrap());
13677 let (_, r4) = real
13678 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13679 .await
13680 .unwrap();
13681 assert_eq!(
13682 r4.deduped_topics,
13683 vec!["tm_test".to_string()],
13684 "a real admission still dedups"
13685 );
13686 }
13687
13688 const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
13694
13695 const WIDE_HEIGHT: u32 = 800_001;
13697
13698 fn wide_leaves(k: u32) -> Vec<String> {
13700 (0..1u32 << k)
13701 .map(|i| {
13702 let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
13703 id.reverse();
13704 hex::encode(id)
13705 })
13706 .collect()
13707 }
13708
13709 fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
13713 let k = txids.len().trailing_zeros() as usize;
13714 let mut levels = vec![Vec::new(); k];
13715 levels[0] = txids
13716 .iter()
13717 .enumerate()
13718 .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
13719 .collect();
13720 let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
13721 let root = bump.compute_root(None).unwrap();
13722 (bump, root)
13723 }
13724
13725 fn w1_bytes(k: u32) -> (Vec<u8>, String) {
13728 let (bump, root) = wide_bump(&wide_leaves(k));
13729 let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
13730 beef.extend_from_slice(&bump.to_binary());
13731 beef.push(0x00);
13732 (beef, root)
13733 }
13734
13735 fn walk_within_bound(
13739 bytes: Vec<u8>,
13740 root: String,
13741 verify_scripts: bool,
13742 subject: String,
13743 what: &str,
13744 ) -> (Result<(), String>, std::time::Duration) {
13745 let (tx, rx) = std::sync::mpsc::channel();
13746 std::thread::spawn(move || {
13747 let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
13748 tracker.add_root(WIDE_HEIGHT, root);
13749 let runtime = tokio::runtime::Builder::new_current_thread()
13750 .build()
13751 .unwrap();
13752 let started = std::time::Instant::now();
13753 let verdict = runtime.block_on(verify_spv_like_the_reference(
13754 Some(&tracker),
13755 verify_scripts,
13756 &bytes,
13757 &subject,
13758 DoorBudget::DEFAULT,
13759 ));
13760 let verdict = match verdict {
13761 Ok(None) => Ok(()),
13762 Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
13763 Err(e) => Err(e.to_string()),
13764 };
13765 let _ = tx.send((verdict, started.elapsed()));
13766 });
13767 rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
13768 panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
13769 })
13770 }
13771
13772 #[test]
13776 fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
13777 for k in [12u32, 13] {
13778 let (bytes, root) = w1_bytes(k);
13779 let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
13780 let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
13781 println!(
13782 "NL-6e W1 {what}: {verdict:?} in {:.3} s",
13783 took.as_secs_f64()
13784 );
13785 assert_eq!(verdict, Ok(()), "{what}");
13786 }
13787 }
13788
13789 fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
13794 use bsv_rs::script::{LockingScript, UnlockingScript};
13795 use bsv_rs::transaction::{TransactionInput, TransactionOutput};
13796 let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
13797 let spending = |txid: &str| {
13798 let mut input = TransactionInput::new(txid.to_string(), 0);
13799 input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
13800 input
13801 };
13802 let parents: Vec<Transaction> = (0..1u32 << k)
13803 .map(|i| {
13804 let mut tx = Transaction::new();
13805 let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
13807 input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
13808 tx.inputs.push(input);
13809 tx.outputs.push(TransactionOutput::new(1, op_true()));
13810 tx
13811 })
13812 .collect();
13813 let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
13814 let (bump, root) = wide_bump(&ids);
13815 let mut beef = Beef::new();
13816 let at = beef.merge_bump(bump);
13817 for parent in &parents {
13818 beef.merge_raw_tx(parent.to_binary(), Some(at));
13819 }
13820 let mut layer = ids;
13821 while layer.len() > 1 {
13822 layer = layer
13823 .chunks(2)
13824 .map(|pair| {
13825 let mut tx = Transaction::new();
13826 tx.inputs.push(spending(&pair[0]));
13827 tx.inputs.push(spending(&pair[1]));
13828 tx.outputs.push(TransactionOutput::new(0, op_true()));
13829 beef.merge_raw_tx(tx.to_binary(), None);
13830 tx.id()
13831 })
13832 .collect();
13833 }
13834 (beef.to_binary(), root, layer.remove(0))
13835 }
13836
13837 #[test]
13838 fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
13839 for k in [12u32, 13] {
13840 let (bytes, root, subject) = proven_fan_in(k);
13841 let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
13842 let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
13843 println!(
13844 "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
13845 took.as_secs_f64()
13846 );
13847 assert_eq!(verdict, Ok(()), "{what}");
13848 }
13849 }
13850 include!("minutes_tests.rs");
13851 include!("memo_tests.rs");
13852 include!("absence_tests.rs");
13853 include!("route_fault_tests.rs");
13854}