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 pub async fn provide_foreign_gasp_nodes(
4032 &self,
4033 outpoints: &[crate::node_batch::BatchOutpoint],
4034 ) -> Result<crate::node_batch::NodeBatchAnswer, EngineError> {
4035 use crate::node_batch::{
4036 refusal, NodeBatchEntry, Packer, NODE_BATCH_MAX_BYTES, NODE_BATCH_MAX_OUTPOINTS,
4037 };
4038 if outpoints.len() > NODE_BATCH_MAX_OUTPOINTS {
4039 return Err(EngineError::InvalidQuery(format!(
4040 "a node batch names at most {NODE_BATCH_MAX_OUTPOINTS} outpoints"
4041 )));
4042 }
4043 let mut packer = Packer::new(NODE_BATCH_MAX_BYTES);
4044 let mut seen = std::collections::HashSet::new();
4045 for outpoint in outpoints {
4046 if !seen.insert(outpoint) {
4047 continue;
4048 }
4049 let graph_id = outpoint.key();
4050 let answer = self
4051 .provide_foreign_gasp_node(&graph_id, &outpoint.txid, outpoint.vout)
4052 .await;
4053 let (node, refused) = match answer {
4054 Ok(node) => (Some(node), None),
4055 Err(EngineError::NodeNotFound) => (None, Some(refusal::NOT_FOUND)),
4056 Err(_) => (None, Some(refusal::ERROR)),
4057 };
4058 if !packer.push(NodeBatchEntry {
4059 txid: outpoint.txid.clone(),
4060 vout: outpoint.vout,
4061 node,
4062 refused: refused.map(str::to_string),
4063 }) {
4064 break;
4065 }
4066 }
4067 Ok(packer.answer)
4068 }
4069
4070 fn hydrate_gasp_node<'a>(
4072 &'a self,
4073 output: &'a Output,
4074 graph_id: &'a str,
4075 txid: &'a str,
4076 output_index: u32,
4077 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GASPNode, EngineError>> + 'a>>
4078 {
4079 Box::pin(async move {
4080 let beef_data = output.beef.as_ref().ok_or(EngineError::NodeNotFound)?;
4081
4082 let root = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
4085 .ok()
4086 .and_then(|mut b| crate::subject::subject_txid_of(&mut b));
4087 let root_tx = beef_limits::transaction_from_beef(
4088 beef_data,
4089 root.as_deref(),
4090 &beef_limits::STORED_BEEF_LIMITS,
4091 )
4092 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
4093
4094 if let Some(node) = Self::search_tx_tree(&root_tx, graph_id, txid, output_index) {
4096 return Ok(node);
4097 }
4098
4099 if let Some(node) = Self::search_beef_for_txid(beef_data, graph_id, txid, output_index)
4102 {
4103 return Ok(node);
4104 }
4105
4106 for consumed in &output.outputs_consumed {
4108 if let Ok(Some(consumed_output)) = self
4109 .storage
4110 .find_output(&consumed.txid, consumed.output_index, None, None, true)
4111 .await
4112 {
4113 if let Ok(node) = self
4114 .hydrate_gasp_node(&consumed_output, graph_id, txid, output_index)
4115 .await
4116 {
4117 return Ok(node);
4118 }
4119 }
4120 }
4121
4122 Err(EngineError::NodeNotFound)
4131 })
4132 }
4133
4134 fn search_tx_tree(
4136 tx: &Transaction,
4137 graph_id: &str,
4138 target_txid: &str,
4139 output_index: u32,
4140 ) -> Option<GASPNode> {
4141 let current_txid = tx.id();
4142
4143 if current_txid == target_txid {
4144 let mut node = GASPNode {
4145 graph_id: graph_id.to_string(),
4146 raw_tx: tx.to_hex(),
4147 output_index,
4148 proof: None,
4149 tx_metadata: None,
4150 output_metadata: None,
4151 inputs: None,
4152 };
4153 if let Some(ref merkle_path) = tx.merkle_path {
4154 node.proof = Some(merkle_path.to_hex());
4155 }
4156 return Some(node);
4157 }
4158
4159 for input in &tx.inputs {
4161 if let Some(ref source_tx) = input.source_transaction {
4162 if let Some(node) =
4163 Self::search_tx_tree(source_tx, graph_id, target_txid, output_index)
4164 {
4165 return Some(node);
4166 }
4167 }
4168 }
4169
4170 None
4171 }
4172
4173 fn search_beef_for_txid(
4177 beef_data: &[u8],
4178 graph_id: &str,
4179 target_txid: &str,
4180 output_index: u32,
4181 ) -> Option<GASPNode> {
4182 if let Ok(tx) = beef_limits::transaction_from_beef(
4184 beef_data,
4185 Some(target_txid),
4186 &beef_limits::STORED_BEEF_LIMITS,
4187 ) {
4188 if tx.id() == target_txid {
4189 let mut node = GASPNode {
4190 graph_id: graph_id.to_string(),
4191 raw_tx: tx.to_hex(),
4192 output_index,
4193 proof: None,
4194 tx_metadata: None,
4195 output_metadata: None,
4196 inputs: None,
4197 };
4198 if let Some(ref merkle_path) = tx.merkle_path {
4199 node.proof = Some(merkle_path.to_hex());
4200 }
4201 return Some(node);
4202 }
4203 }
4204 None
4205 }
4206
4207 pub async fn handle_new_merkle_proof(
4221 &self,
4222 txid: &str,
4223 proof_hex: &str,
4224 block_height: Option<u32>,
4225 ) -> Result<(), EngineError> {
4226 let outputs = self
4227 .storage
4228 .find_outputs_for_transaction(txid, true)
4229 .await
4230 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4231
4232 if outputs.is_empty() {
4233 return Err(EngineError::Other(
4234 "Could not find matching transaction outputs for proof ingest!".into(),
4235 ));
4236 }
4237
4238 let proof = if proof_hex.is_empty() {
4240 None
4241 } else {
4242 Some(
4243 beef_limits::merkle_path_from_hex(proof_hex, beef_limits::PUSH_PROOF_MAX_BYTES)
4244 .map_err(|e| EngineError::Other(format!("Invalid merkle proof hex: {e}")))?,
4245 )
4246 };
4247
4248 for output in &outputs {
4249 if let (Some(ref beef_data), Some(ref proof)) = (&output.beef, &proof) {
4252 if let Some(new_beef) = Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4253 {
4254 let _ = self
4255 .storage
4256 .update_transaction_beef(&output.txid, &new_beef)
4257 .await;
4258 }
4259 }
4260
4261 if let Some(height) = block_height {
4263 let _ = self
4264 .storage
4265 .update_output_block_height(
4266 &output.txid,
4267 output.output_index,
4268 &output.topic,
4269 height,
4270 )
4271 .await;
4272 }
4273
4274 for consuming in &output.consumed_by {
4276 if let Ok(consumed_outputs) = self
4277 .storage
4278 .find_outputs_for_transaction(&consuming.txid, true)
4279 .await
4280 {
4281 for consumed_output in &consumed_outputs {
4282 if let (Some(ref beef_data), Some(ref proof)) =
4288 (&consumed_output.beef, &proof)
4289 {
4290 if let Some(new_beef) =
4291 Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4292 {
4293 let _ = self
4294 .storage
4295 .update_transaction_beef(&consumed_output.txid, &new_beef)
4296 .await;
4297 }
4298 }
4299 }
4300 }
4301 }
4302 }
4303
4304 Ok(())
4305 }
4306
4307 pub async fn complete_missing_proofs(
4343 &self,
4344 limit: u64,
4345 min_age_secs: u64,
4346 ) -> Result<ProofCompletionSummary, EngineError> {
4347 let Some(fetcher) = self.ancestor_fetcher.as_deref() else {
4348 return Ok(ProofCompletionSummary::default());
4349 };
4350 self.complete_missing_proofs_with(fetcher, limit, min_age_secs)
4351 .await
4352 }
4353
4354 pub async fn complete_missing_proofs_with(
4357 &self,
4358 fetcher: &dyn crate::gasp::AncestorFetcher,
4359 limit: u64,
4360 min_age_secs: u64,
4361 ) -> Result<ProofCompletionSummary, EngineError> {
4362 let mut summary = ProofCompletionSummary::default();
4363
4364 let candidates = self
4365 .storage
4366 .find_transactions_for_proof_check(limit, min_age_secs)
4367 .await
4368 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4369 summary.scanned = candidates.len();
4370
4371 for cand in candidates {
4372 summary.early_gasp += usize::from(cand.early_gasp);
4373 let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4377 Ok(b) => b,
4378 Err(e) => {
4379 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4380 continue;
4381 }
4382 };
4383
4384 if let Some(bump_hex) = own_bump
4402 .as_ref()
4403 .map(bsv_rs::transaction::MerklePath::to_hex)
4404 {
4405 if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4406 let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4414 .ok()
4415 .map(|mp| u64::from(mp.block_height));
4416 if let Err(e) = self
4417 .storage
4418 .mark_transaction_proven_at(&cand.txid, height)
4419 .await
4420 {
4421 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4422 } else {
4423 summary.already_proven += 1;
4424 }
4425 continue;
4426 }
4427 warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4428 }
4429 summary.proofless += 1;
4431
4432 let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4437 summary.still_unconfirmed += 1;
4438 continue;
4439 };
4440
4441 let block_height =
4443 beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4444 .ok()
4445 .map(|mp| mp.block_height);
4446
4447 match self
4448 .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4449 .await
4450 {
4451 Ok(()) => summary.completed += 1,
4452 Err(e) => {
4453 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4454 summary.stitch_failed += 1;
4455 }
4456 }
4457 }
4458
4459 info!(
4460 scanned = summary.scanned,
4461 proofless = summary.proofless,
4462 completed = summary.completed,
4463 still_unconfirmed = summary.still_unconfirmed,
4464 fetch_failed = summary.fetch_failed,
4465 stitch_failed = summary.stitch_failed,
4466 already_proven = summary.already_proven,
4467 early_gasp = summary.early_gasp,
4468 "[PROOF COMPLETION] pass complete"
4469 );
4470
4471 Ok(summary)
4472 }
4473
4474 pub fn stitch_proof_into_stored_beef(
4515 stored: &[u8],
4516 txid: &str,
4517 proof: &bsv_rs::transaction::MerklePath,
4518 ) -> Option<Vec<u8>> {
4519 let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4520 beef.find_txid(txid)?;
4521 let i = beef.merge_bump(proof.clone());
4522 let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4526 b.path.first().is_some_and(|leaves| {
4527 leaves
4528 .iter()
4529 .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4530 })
4531 });
4532 if proves_subject {
4533 if let Some(tx) = beef.find_txid_mut(txid) {
4534 tx.set_bump_index(Some(i));
4535 }
4536 }
4537 Self::gc_unreferenced_bumps(&mut beef);
4538 Some(beef.to_binary())
4539 }
4540
4541 fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4547 let referenced: std::collections::HashSet<usize> = beef
4548 .txs
4549 .iter()
4550 .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4551 .collect();
4552 if referenced.len() == beef.bumps.len() {
4553 return;
4554 }
4555 let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4556 let mut kept = Vec::with_capacity(referenced.len());
4557 for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4558 if referenced.contains(&old) {
4559 remap[old] = Some(kept.len());
4560 kept.push(bump);
4561 }
4562 }
4563 beef.bumps = kept;
4564 for tx in &mut beef.txs {
4565 if let Some(old) = tx.bump_index() {
4566 tx.set_bump_index(remap[old]);
4567 }
4568 }
4569 }
4570
4571 pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4585 let mut report = SyncAdvertisementsReport::default();
4586
4587 let Some(advertiser) = &self.advertiser else {
4588 return Ok(report); };
4590
4591 let hosting_url = match &self.config.hosting_url {
4592 Some(url) if !url.is_empty() => url.clone(),
4593 _ => return Ok(report), };
4595
4596 let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4598 let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4599
4600 if self.config.suppress_default_sync_advertisements {
4601 configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4602 configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4603 }
4604
4605 let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4610 Ok(ads) => ads,
4611 Err(e) => {
4612 error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4613 report.lookup_error = Some(format!("ship: {e}"));
4614 return Ok(report);
4615 }
4616 };
4617 let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4618 Ok(ads) => ads,
4619 Err(e) => {
4620 error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4621 report.lookup_error = Some(format!("slap: {e}"));
4622 return Ok(report);
4623 }
4624 };
4625
4626 let ships_to_create: Vec<AdvertisementData> = configured_topics
4628 .iter()
4629 .filter(|topic| {
4630 !current_ship
4631 .iter()
4632 .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4633 })
4634 .map(|topic| AdvertisementData {
4635 protocol: Protocol::Ship,
4636 topic_or_service_name: topic.clone(),
4637 })
4638 .collect();
4639
4640 let slaps_to_create: Vec<AdvertisementData> = configured_services
4641 .iter()
4642 .filter(|service| {
4643 !current_slap
4644 .iter()
4645 .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4646 })
4647 .map(|service| AdvertisementData {
4648 protocol: Protocol::Slap,
4649 topic_or_service_name: service.clone(),
4650 })
4651 .collect();
4652
4653 let ships_to_revoke: Vec<Advertisement> = current_ship
4655 .into_iter()
4656 .filter(|a| !configured_topics.contains(&a.topic_or_service))
4657 .collect();
4658
4659 let slaps_to_revoke: Vec<Advertisement> = current_slap
4660 .into_iter()
4661 .filter(|a| !configured_services.contains(&a.topic_or_service))
4662 .collect();
4663
4664 let mut all_to_create = ships_to_create;
4666 all_to_create.extend(slaps_to_create);
4667 report.to_create = all_to_create.len();
4668 if !all_to_create.is_empty() {
4669 match advertiser.create_advertisements(&all_to_create).await {
4670 Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4671 Ok(steak) => {
4672 for (topic, instructions) in &steak {
4673 report
4674 .admitted
4675 .insert(topic.clone(), instructions.outputs_to_admit.len());
4676 }
4677 }
4678 Err(e) => {
4679 error!("Failed to submit new advertisements: {e}");
4680 report.submit_error = Some(e.to_string());
4681 }
4682 },
4683 Err(e) => {
4684 error!("Failed to create advertisements: {e}");
4685 report.create_error = Some(e.to_string());
4686 }
4687 }
4688 }
4689
4690 let mut all_to_revoke = ships_to_revoke;
4692 all_to_revoke.extend(slaps_to_revoke);
4693 report.to_revoke = all_to_revoke.len();
4694 if !all_to_revoke.is_empty() {
4695 match advertiser.revoke_advertisements(&all_to_revoke).await {
4696 Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4702 report.revoke_skipped = all_to_revoke.len();
4703 }
4704 Ok(tagged_beef) => {
4705 if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4706 error!("Failed to submit revocation: {e}");
4707 report.revoke_submit_error = Some(e.to_string());
4708 }
4709 }
4710 Err(e) => {
4711 error!("Failed to revoke advertisements: {e}");
4712 report.revoke_error = Some(e.to_string());
4713 }
4714 }
4715 }
4716
4717 Ok(report)
4718 }
4719
4720 #[allow(dead_code)] fn delete_utxo_deep<'a>(
4734 &'a self,
4735 output: &'a Output,
4736 bound: Option<&'a CallBound>,
4737 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4738 Box::pin(async move {
4739 if output.consumed_by.is_empty() {
4741 stored(
4742 bound,
4743 self.storage
4744 .delete_output(&output.txid, output.output_index, &output.topic),
4745 )
4746 .await
4747 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4748
4749 for ls in self.lookup_services.values() {
4752 let _ = hooked(
4753 bound,
4754 ls.output_no_longer_retained_in_history(
4755 &output.txid,
4756 output.output_index,
4757 &output.topic,
4758 ),
4759 )
4760 .await;
4761 }
4762 }
4763
4764 for consumed in &output.outputs_consumed {
4766 if let Ok(Some(mut stale_output)) = stored(
4767 bound,
4768 self.storage.find_output(
4769 &consumed.txid,
4770 consumed.output_index,
4771 Some(&output.topic),
4772 None,
4773 false,
4774 ),
4775 )
4776 .await
4777 {
4778 stale_output.consumed_by.retain(|c| {
4780 !(c.txid == output.txid && c.output_index == output.output_index)
4781 });
4782
4783 stored(
4784 bound,
4785 self.storage.update_consumed_by(
4786 &consumed.txid,
4787 consumed.output_index,
4788 &output.topic,
4789 &stale_output.consumed_by,
4790 ),
4791 )
4792 .await
4793 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4794
4795 let _ = self.delete_utxo_deep(&stale_output, bound).await;
4797 }
4798 }
4799
4800 Ok(())
4801 }) }
4803
4804 pub async fn renotify_admitted(
4835 &self,
4836 txid: &str,
4837 topic: &str,
4838 ) -> Result<RenotifyReport, EngineError> {
4839 let txid = txid.to_ascii_lowercase();
4840 let outputs = self
4841 .storage
4842 .find_outputs_for_transaction(&txid, true)
4843 .await
4844 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4845 let mut report = RenotifyReport::default();
4846 for output in outputs.iter().filter(|o| o.topic == topic) {
4847 report.outputs += 1;
4848 report.vouts.push(output.output_index);
4849 let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4852 beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4853 .ok()
4854 .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4855 .unwrap_or_else(|| beef.clone())
4856 });
4857 for (name, ls) in &self.lookup_services {
4858 let payload = match ls.admission_mode() {
4859 AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4860 txid: txid.clone(),
4861 output_index: output.output_index,
4862 topic: topic.to_string(),
4863 satoshis: output.satoshis,
4864 locking_script: output.output_script.clone(),
4865 off_chain_values: None,
4866 },
4867 AdmissionMode::WholeTx => {
4868 let Some(atomic_beef) = subject_named.as_ref() else {
4869 report.faults.push(format!(
4870 "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4871 output.output_index
4872 ));
4873 continue;
4874 };
4875 OutputAdmittedByTopic::WholeTx {
4876 atomic_beef: atomic_beef.clone(),
4877 output_index: output.output_index,
4878 topic: topic.to_string(),
4879 off_chain_values: None,
4880 }
4881 }
4882 };
4883 match ls.output_admitted_by_topic(&payload).await {
4884 Ok(()) => report.notified += 1,
4885 Err(e) => report.faults.push(format!("{name}: {e}")),
4886 }
4887 }
4888 }
4889 Ok(report)
4890 }
4891
4892 pub async fn forget_phantom_applied(
4903 &self,
4904 txid: &str,
4905 topic: &str,
4906 ) -> Result<bool, EngineError> {
4907 let txid = txid.to_ascii_lowercase();
4908 let outputs = self
4909 .storage
4910 .find_outputs_for_transaction(&txid, false)
4911 .await
4912 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4913 if outputs.iter().any(|o| o.topic == topic) {
4914 return Ok(false);
4915 }
4916 let rec = AppliedTransaction {
4917 txid: txid.clone(),
4918 topic: topic.to_string(),
4919 };
4920 let exists = self
4921 .storage
4922 .does_applied_transaction_exist(&rec)
4923 .await
4924 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4925 if !exists {
4926 return Ok(false);
4927 }
4928 self.storage
4929 .delete_applied_transaction(&rec)
4930 .await
4931 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4932 Ok(true)
4933 }
4934
4935 pub async fn evict_output(
4936 &self,
4937 txid: &str,
4938 output_index: u32,
4939 topic: Option<&str>,
4940 ) -> Result<(), EngineError> {
4941 if let Some(topic) = topic {
4942 self.storage
4944 .delete_output(txid, output_index, topic)
4945 .await
4946 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4947
4948 for ls in self.lookup_services.values() {
4950 let _ = ls.output_evicted(txid, output_index).await;
4951 }
4952 } else {
4953 let outputs = self
4955 .storage
4956 .find_outputs_for_transaction(txid, false)
4957 .await
4958 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4959
4960 for output in &outputs {
4961 if output.output_index == output_index {
4962 self.storage
4963 .delete_output(txid, output_index, &output.topic)
4964 .await
4965 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4966 }
4967 }
4968
4969 for ls in self.lookup_services.values() {
4971 let _ = ls.output_evicted(txid, output_index).await;
4972 }
4973 }
4974
4975 Ok(())
4976 }
4977
4978 pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
5003 self.start_gasp_sync_filtered(&crate::sync_load::SyncFilter::default())
5004 .await
5005 }
5006
5007 pub async fn start_gasp_sync_filtered(
5009 &self,
5010 filter: &crate::sync_load::SyncFilter,
5011 ) -> Result<GASPSyncResult, EngineError> {
5012 let meter = crate::meter::LoadMeter::default();
5013 self.start_gasp_sync_metered(filter, &meter).await
5014 }
5015
5016 pub async fn start_gasp_sync_metered(
5018 &self,
5019 filter: &crate::sync_load::SyncFilter,
5020 meter: &crate::meter::LoadMeter,
5021 ) -> Result<GASPSyncResult, EngineError> {
5022 let run = self.gasp_run();
5023 meter.scope(self.run_gasp_sync(filter, &run, None)).await
5024 }
5025
5026 fn gasp_run(&self) -> crate::backfill::Run {
5027 crate::backfill::Run::new(
5028 self.gasp_ancestor_fetcher
5029 .as_ref()
5030 .or(self.ancestor_fetcher.as_ref())
5031 .cloned(),
5032 )
5033 }
5034
5035 pub async fn start_gasp_backfill_metered(
5038 &self,
5039 request: &crate::backfill::SyncRequest,
5040 meter: &crate::meter::LoadMeter,
5041 ) -> Result<crate::backfill::BackfillResult, EngineError> {
5042 use crate::backfill::{
5043 BackfillResult, Continuation, Control, BACKFILL_MAX_SLICES, BACKFILL_REQUEST_MS,
5044 };
5045 request.validate().map_err(EngineError::InvalidQuery)?;
5046 let (topic, peer) = request.pair().map_err(EngineError::InvalidQuery)?;
5047 if request.until.is_none() || self.peer_sync_budget.is_none() || self.graph_budget.is_none()
5048 {
5049 return Err(EngineError::InvalidQuery(
5050 "backfill requires until: tip and peer/graph bounds".into(),
5051 ));
5052 }
5053 if self.gasp_remote_factory.is_none()
5054 || !matches!(
5055 self.config.sync_configuration.get(topic),
5056 Some(SyncTarget::Ship | SyncTarget::Peers(_))
5057 )
5058 {
5059 return Err(EngineError::InvalidQuery(
5060 "backfill topic is not enabled for sync".into(),
5061 ));
5062 }
5063 meter
5064 .scope(async {
5065 let initial = self.storage.get_last_interaction(peer, topic).await?;
5066 if let Some(token) = &request.continuation {
5067 let token = Continuation::decode(token).map_err(EngineError::InvalidQuery)?;
5068 if token.cursor > initial {
5069 return Err(EngineError::InvalidQuery(
5070 "continuation is ahead of the durable cursor".into(),
5071 ));
5072 }
5073 }
5074 let filter = request.filter();
5075 let run = self.gasp_run();
5076 let control = Control {
5077 meter: meter.clone(),
5078 budget_ms: request.budget_ms.unwrap_or(0).min(BACKFILL_REQUEST_MS),
5079 };
5080 let mut result = BackfillResult {
5081 sync: GASPSyncResult {
5082 filter: filter.clone(),
5083 ..GASPSyncResult::default()
5084 },
5085 slices: 0,
5086 reached_tip: false,
5087 stop_reason: "slice_allowance".into(),
5088 continuation: None,
5089 };
5090 let mut previous_cursor = initial;
5091 for _ in 0..BACKFILL_MAX_SLICES {
5092 if let Some(reason) = control.stop_reason() {
5093 result.stop_reason = reason.into();
5094 break;
5095 }
5096 let slice = Box::pin(self.run_gasp_sync(&filter, &run, Some(&control))).await?;
5100 let topic_result = slice.topics_synced.get(topic).ok_or_else(|| {
5101 EngineError::InvalidQuery("backfill selection matched no topic".into())
5102 })?;
5103 if !topic_result.peers.iter().any(|p| p == peer) {
5104 return Err(EngineError::InvalidQuery(
5105 "backfill selection matched no peer".into(),
5106 ));
5107 }
5108 let summary = topic_result.peer_results.get(peer).cloned();
5109 let waiting = !topic_result.peers_skipped.is_empty();
5110 result.sync.append_slice(slice);
5111 if let Some(summary) = summary {
5112 result.slices += summary.slices;
5113 if summary.reached_tip {
5114 result.reached_tip = true;
5115 result.stop_reason = "tip".into();
5116 break;
5117 }
5118 if let Some(reason) = control.stop_reason() {
5119 result.stop_reason = reason.into();
5120 break;
5121 }
5122 if summary.cursor <= previous_cursor {
5123 result.stop_reason = "no_progress".into();
5124 break;
5125 }
5126 previous_cursor = summary.cursor;
5127 } else {
5128 result.stop_reason =
5129 if waiting { "peer_wait" } else { "no_progress" }.into();
5130 break;
5131 }
5132 }
5133 let cursor = self.storage.get_last_interaction(peer, topic).await?;
5134 if !result.reached_tip {
5135 result.continuation = Some(Continuation::new(topic, peer, cursor).encode());
5136 }
5137 result.sync.cost = meter.snapshot();
5138 Ok(result)
5139 })
5140 .await
5141 }
5142
5143 async fn run_gasp_sync(
5144 &self,
5145 filter: &crate::sync_load::SyncFilter,
5146 run: &crate::backfill::Run,
5147 control: Option<&crate::backfill::Control>,
5148 ) -> Result<GASPSyncResult, EngineError> {
5149 use crate::gasp::{GASPSync, ENGINE_GASP_SYNC_PAGE_LIMIT};
5150 use crate::gasp_overlay::OverlayGASPStorage;
5151
5152 if self.config.sync_configuration.is_empty() {
5153 info!("[GASP SYNC] No sync configuration — nothing to sync");
5154 return Ok(GASPSyncResult {
5155 cost: crate::meter::SyncCost::default(),
5156 topics_synced: HashMap::new(),
5157 filter: filter.clone(),
5158 topics_filtered: Vec::new(),
5159 });
5160 }
5161
5162 let sync_fetcher = &run.fetcher;
5163 let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
5164 let mut ship_lookup_hosts = None;
5167 let mut topic_order = sync_order(&self.config.sync_configuration);
5168 topic_order.sort_by_key(|(topic, target)| {
5171 !(matches!(target, SyncTarget::Ship)
5172 && self
5173 .ship_bootstrap_peers
5174 .get(topic.as_str())
5175 .is_some_and(|peers| !peers.is_empty()))
5176 });
5177
5178 let mut topics_filtered = Vec::new();
5179 for (topic, target) in topic_order {
5180 if filter
5181 .topics
5182 .as_ref()
5183 .is_some_and(|names| !names.contains(topic))
5184 {
5185 topics_filtered.push(topic.clone());
5186 continue;
5187 }
5188 let cost_before = crate::meter::snapshot();
5189 let configured_peers = matches!(target, SyncTarget::Peers(_));
5192 let (peers, sync_type) = match target {
5193 SyncTarget::Disabled => {
5194 info!("[GASP SYNC] Topic {topic} is disabled — skipping");
5195 continue;
5196 }
5197 SyncTarget::Ship => {
5198 info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
5199 let peers = if let Some(peers) = &filter.peers {
5200 self.ship_peers_excluding_self(peers.clone())
5201 } else {
5202 self.discover_ship_peers(topic, &mut ship_lookup_hosts)
5203 .await
5204 };
5205 info!(
5206 "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
5207 peers.len()
5208 );
5209 (peers, "ship".to_string())
5210 }
5211 SyncTarget::Peers(peer_urls) => {
5212 info!(
5213 "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
5214 peer_urls.len()
5215 );
5216 let peers: Vec<String> = peer_urls
5217 .iter()
5218 .filter(|url| {
5219 if let Some(ref our_url) = self.config.hosting_url {
5220 url.trim_end_matches('/') != our_url.trim_end_matches('/')
5221 } else {
5222 true
5223 }
5224 })
5225 .cloned()
5226 .collect();
5227
5228 info!(
5229 "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
5230 peers.len()
5231 );
5232 (peers, "peers".to_string())
5233 }
5234 };
5235
5236 let (peers, peers_filtered): (Vec<_>, Vec<_>) = peers.into_iter().partition(|peer| {
5237 filter
5238 .peers
5239 .as_ref()
5240 .is_none_or(|names| names.contains(peer))
5241 });
5242 let mut peers_skipped = Vec::new();
5243 let advertisements = self.peer_advertisement_versions(topic).await;
5244 let mut errors = Vec::new();
5245 let mut pruned_inputs: u64 = 0;
5246 let mut discarded_graphs: u64 = 0;
5247 let mut expired_refused: u64 = 0;
5248 let mut roots_proven = 0;
5249 let mut roots_unproven = 0;
5250 let mut cursor_held_by = Vec::new();
5251 let expired = run.expired(topic);
5252 let mut peer_results = HashMap::new();
5253 let mut anchor_walk_held_graphs: u64 = 0;
5254 let mut finalized_graphs: u64 = 0;
5255 let mut deadline_dropped_graphs: u64 = 0;
5256 let mut cursor_moves: Vec<CursorMove> = Vec::new();
5257 let mut deferred_graphs: u64 = 0;
5258 let mut resumed_graphs: u64 = 0;
5259 let mut converged_graphs: u64 = 0;
5260 let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
5261 let mut stalled_graphs: u64 = 0;
5262 let mut held_back_graphs: u64 = 0;
5263 let mut dead_graphs = 0;
5264 let mut expired_swept = 0;
5265 let mut memo_folded = 0;
5266 let mut unanchorable_graphs: u64 = 0;
5267 let mut unanchorable_cleared: u64 = 0;
5268 let mut absent_struck: u64 = 0;
5269 let mut route_faults: Vec<crate::route_fault::RouteFaultSeen> = Vec::new();
5270 let mut strikes_refused: u64 = 0;
5271 let mut backed_off_graphs: u64 = 0;
5272 let mut waiting_graphs: u64 = 0;
5273 let mut idle_counted: u64 = 0;
5274 let mut root_bounds = Vec::new();
5275 let mut faulted_roots: Vec<crate::gasp::FaultedRoot> = Vec::new();
5276
5277 if let Some(ref factory) = self.gasp_remote_factory {
5279 for peer_url in &peers {
5280 let health_key = crate::gasp::peer_origin(peer_url);
5293 let health = self
5294 .storage
5295 .get_peer_sync_health(&health_key, topic)
5296 .await
5297 .unwrap_or_default();
5298 if crate::gasp::peer_sync_quarantined(&health) {
5299 peers_skipped.push(crate::sync_load::SkippedPeer {
5300 peer: peer_url.clone(),
5301 reason: "quarantine".into(),
5302 retry_after_secs: crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5303 .saturating_sub(health.secs_since_last_attempt.unwrap_or(0)),
5304 });
5305 warn!(
5306 "[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)",
5307 health.consecutive_failures,
5308 health.secs_since_last_attempt,
5309 crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5310 );
5311 continue;
5312 }
5313
5314 let cadence = self
5315 .storage
5316 .get_peer_cadence(&health_key, topic)
5317 .await
5318 .unwrap_or_default();
5319 let advertisement =
5320 advertisements.get(&health_key).cloned().unwrap_or_default();
5321 let reset_cadence = filter.explicit() || cadence.advertisement != advertisement;
5322 if !reset_cadence && cadence.retry_after_secs > 0 {
5323 peers_skipped.push(crate::sync_load::SkippedPeer {
5324 peer: peer_url.clone(),
5325 reason: "no_yield".into(),
5326 retry_after_secs: cadence.retry_after_secs,
5327 });
5328 continue;
5329 }
5330
5331 let peer_cost_before = crate::meter::snapshot();
5332 info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
5333
5334 let last_interaction = self
5336 .storage
5337 .get_last_interaction(peer_url, topic)
5338 .await
5339 .unwrap_or(0);
5340
5341 let sink = crate::gasp_overlay::new_finalized_graph_sink();
5343
5344 let hydration_on = sync_fetcher.is_some();
5353
5354 let mut gasp_storage =
5356 OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5357 .with_peer(peer_url.as_str())
5358 .with_configured_peer(configured_peers)
5359 .with_strict_beef(hydration_on)
5360 .with_script_verification(self.verify_scripts)
5361 .with_walk_budget(self.walk_budget);
5362 if let Some(manager) = self.managers.get(topic) {
5363 gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5364 }
5365 if let Some(tracker) = self.chain_tracker.as_deref() {
5370 gasp_storage = gasp_storage.with_chain_tracker(tracker);
5371 }
5372 let gasp_remote = Box::new(crate::load_remote::MeteredRemote(
5373 factory.create_remote(peer_url, topic),
5374 ));
5375
5376 let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5377 let peer_finalized = std::cell::Cell::new(0u64);
5379 let mut sync = GASPSync::new(
5380 Box::new(gasp_storage),
5381 gasp_remote,
5382 last_interaction,
5383 &log_prefix,
5384 true, )
5386 .with_ancestor_fetcher(sync_fetcher.clone())
5387 .with_expired_outpoints(std::rc::Rc::clone(&expired))
5388 .with_batch_memo(run.single_only(peer_url));
5389 if let Some(control) = control {
5390 let control = control.clone();
5391 sync = sync.with_page_slice(std::rc::Rc::new(move || {
5392 control.stop_reason().is_some()
5393 }));
5394 }
5395 if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5398 let (sleep, budget_ms) =
5399 (sleep.clone(), (*budget_ms).min(crate::meter::ROOT_MAX_MS));
5400 let half_sleep = sleep.clone();
5401 let share_sleep = sleep.clone();
5402 sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5403 max_calls: *max_calls,
5404 max_bytes_fetched: self.graph_budget_limbs.0,
5405 max_nodes: self.graph_budget_limbs.1,
5406 deadline: Box::new(move || sleep(budget_ms)),
5407 half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5408 });
5409 if let Some((_, peer_ms)) = &self.peer_sync_budget {
5412 let share_ms = crate::gasp::resume_share_ms(*peer_ms, budget_ms);
5413 sync = sync.with_resume_share(Box::new(move || share_sleep(share_ms)));
5414 }
5415 }
5416 if self.peer_sync_budget.is_some() {
5421 sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5422 engine: self,
5423 sink: sink.clone(),
5424 peer_url,
5425 topic,
5426 submitted: &peer_finalized,
5427 }));
5428 }
5429
5430 let sync_outcome = match &self.peer_sync_budget {
5440 Some((sleep, budget_ms)) => {
5441 let budget_ms =
5442 control.map_or(*budget_ms, |c| (*budget_ms).min(c.remaining_ms()));
5443 let mut deadline = sleep(budget_ms);
5444 let deadline = std::future::poll_fn(|cx| {
5445 if control.is_some_and(|c| c.stop_reason().is_some())
5446 || std::future::Future::poll(deadline.as_mut(), cx).is_ready()
5447 {
5448 std::task::Poll::Ready(())
5449 } else {
5450 std::task::Poll::Pending
5451 }
5452 });
5453 crate::gasp::race_or_deadline_guarded(
5454 sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5455 deadline,
5456 &self.finalize_gate,
5457 )
5458 .await
5459 }
5460 None => Some(sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)).await),
5461 };
5462
5463 let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5464 let route_faulted = matches!(
5467 sync_outcome,
5468 Some(Err(crate::gasp::GASPError::RouteFault { .. }))
5469 );
5470 pruned_inputs += sync.pruned_inputs();
5476 expired_refused += sync.expired_refused();
5477 roots_proven += sync.roots_proven();
5478 roots_unproven += sync.roots_unproven();
5479 discarded_graphs += sync.discarded_graphs();
5484 anchor_walk_held_graphs += sync.anchor_walk_held();
5485 let ran = !matches!(sync_outcome, Some(Err(_)));
5489 let mut advanced_cursor = false;
5490 match sync_outcome {
5491 None => {
5492 let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5493 let msg = format!(
5494 "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5495 );
5496 warn!("[GASP SYNC] {msg}");
5497 errors.push(msg);
5498
5499 let in_flight = u64::from(sync.graph_in_flight());
5510 deadline_dropped_graphs += in_flight;
5511 sync.defer_in_flight().await;
5514 let completed = sync.completed_cursor();
5515 if completed > last_interaction {
5516 match self
5517 .storage
5518 .update_last_interaction(peer_url, topic, completed)
5519 .await
5520 {
5521 Ok(()) => cursor_moves.push(CursorMove {
5522 peer: peer_url.clone(),
5523 from: last_interaction,
5524 to: completed,
5525 }),
5526 Err(e) => warn!(
5527 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5528 ),
5529 }
5530 }
5531 advanced_cursor = completed > last_interaction;
5551 outcome_success = peer_finalized.get() > 0
5552 || (completed > last_interaction && sync.cursor_yielded())
5553 || sync.deferral_stats().progressed > 0
5554 || sync.did_work();
5555 warn!(
5556 "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5557 peer_finalized.get(),
5558 completed.max(last_interaction)
5559 );
5560 }
5561 Some(Ok(())) => {
5562 let landed = self
5566 .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5567 .await;
5568 let stats = sync.deferral_stats();
5579 if stats.stalled > 0
5580 && stats.progressed == 0
5581 && peer_finalized.get() == 0
5582 && !sync.did_work()
5583 && !(landed
5584 && sync.last_interaction > last_interaction
5585 && sync.cursor_yielded())
5586 {
5587 outcome_success = false;
5588 let msg = format!(
5589 "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5590 stats.stalled
5591 );
5592 warn!("[GASP SYNC] {msg}");
5593 errors.push(msg);
5594 }
5595
5596 advanced_cursor = landed && sync.last_interaction > last_interaction;
5615 if !landed {
5616 warn!(
5617 "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5618 );
5619 } else if sync.last_interaction > last_interaction {
5620 match self
5621 .storage
5622 .update_last_interaction(peer_url, topic, sync.last_interaction)
5623 .await
5624 {
5625 Ok(()) => cursor_moves.push(CursorMove {
5626 peer: peer_url.clone(),
5627 from: last_interaction,
5628 to: sync.last_interaction,
5629 }),
5630 Err(e) => warn!(
5631 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5632 ),
5633 }
5634 }
5635 info!(
5636 "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5637 sync.last_interaction
5638 );
5639 }
5640 Some(Err(e)) => {
5641 let msg = format!("{peer_url}: {e}");
5642 warn!("[GASP SYNC] Sync failed: {msg}");
5643 errors.push(msg);
5644 if let crate::gasp::GASPError::RouteFault { class, .. } = e {
5645 route_faults.push(crate::route_fault::RouteFaultSeen {
5646 peer: peer_url.clone(),
5647 class,
5648 });
5649 }
5650 }
5651 }
5652 let yielded = peer_finalized.get() > 0
5669 || (advanced_cursor && sync.cursor_yielded())
5670 || sync.expired_refused() > 0;
5671 if yielded || sync.did_work() {
5672 outcome_success = true;
5673 }
5674 let route_excused = route_faulted && !outcome_success;
5682 if route_excused {
5683 info!(
5684 "[GASP SYNC] {peer_url} for {topic}: {} before the peer was reached; nothing counted against the peer (#57)",
5685 route_faults
5686 .last()
5687 .map_or(String::new(), crate::route_fault::RouteFaultSeen::label)
5688 );
5689 }
5690 if ran
5701 && self.graph_budget.is_some()
5702 && (yielded || sync.graphs_attempted() > 0)
5703 {
5704 match self
5705 .storage
5706 .record_peer_sync_yield(&health_key, topic, yielded)
5707 .await
5708 {
5709 Ok(streak)
5710 if !yielded
5711 && crate::gasp::yieldless_sync_failed(&streak)
5712 && outcome_success =>
5713 {
5714 outcome_success = false;
5715 let msg = format!(
5716 "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5717 streak.yieldless_syncs,
5718 streak.secs_since_first.unwrap_or(0),
5719 crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5720 crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5721 );
5722 warn!("[GASP SYNC] {msg}");
5723 errors.push(msg);
5724 }
5725 Ok(_) => {}
5726 Err(e) => warn!(
5727 "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5728 ),
5729 }
5730 }
5731 let quiet = outcome_success
5734 && !yielded
5735 && !sync.did_work()
5736 && sync.faulted_roots().is_empty()
5737 && sync.deferral_stats().stalled == 0;
5738 let quiet_syncs = if quiet && !filter.explicit() {
5739 if reset_cadence {
5740 1
5741 } else {
5742 cadence.quiet_syncs.saturating_add(1)
5743 }
5744 } else {
5745 0
5746 };
5747 let next = crate::sync_load::PeerCadence {
5748 quiet_syncs,
5749 retry_after_secs: crate::sync_load::quiet_retry_secs(quiet_syncs),
5750 advertisement,
5751 };
5752 if !route_excused {
5753 if let Err(e) = self
5754 .storage
5755 .set_peer_cadence(&health_key, topic, &next)
5756 .await
5757 {
5758 warn!(
5759 "[GASP SYNC] failed to record cadence for {peer_url}/{topic}: {e}"
5760 );
5761 }
5762 }
5763 if !advanced_cursor {
5764 if let Some(outpoint) = sync.cursor_held_by() {
5765 cursor_held_by.push(outpoint);
5766 }
5767 }
5768 finalized_graphs += peer_finalized.get();
5769 let deferral = sync.deferral_stats();
5770 root_bounds.extend(deferral.root_bounds.iter().cloned().map(|mut bound| {
5771 bound.peer.clone_from(peer_url);
5772 bound
5773 }));
5774 deferred_graphs += deferral.deferred;
5775 resumed_graphs += deferral.resumed;
5776 converged_graphs += deferral.converged;
5777 stalled_graphs += deferral.stalled;
5778 held_back_graphs += deferral.held_back;
5779 backed_off_graphs += deferral.backed_off;
5780 waiting_graphs += deferral.waiting;
5781 idle_counted += deferral.idle_counted;
5782 dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5783 peer: peer_url.clone(),
5784 outpoint: d.outpoint,
5785 reason: d.reason.as_str().to_string(),
5786 }));
5787 dead_graphs += sync.dead_recorded();
5789 expired_swept += sync.expired_swept();
5790 memo_folded += sync.memo_folded();
5791 unanchorable_graphs += sync.unanchorable_recorded();
5792 unanchorable_cleared += sync.unanchorable_cleared();
5793 absent_struck += sync.absent_struck();
5794 strikes_refused += sync.strikes_refused();
5796 faulted_roots.extend(sync.faulted_roots().into_iter().map(|mut f| {
5797 f.peer.clone_from(peer_url);
5798 f
5799 }));
5800
5801 if route_excused {
5821 } else if outcome_success
5823 && !yielded
5824 && self.graph_budget.is_some()
5825 && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5826 {
5827 info!(
5828 "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5829 health.consecutive_failures
5830 );
5831 } else if let Err(e) = self
5832 .storage
5833 .record_peer_sync_outcome(&health_key, topic, outcome_success)
5834 .await
5835 {
5836 warn!(
5837 "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5838 );
5839 }
5840 let cursor = self.storage.get_last_interaction(peer_url, topic).await?;
5841 let cost = crate::meter::snapshot().since(peer_cost_before);
5842 peer_results.insert(
5843 peer_url.clone(),
5844 crate::backfill::PeerSyncResult {
5845 slices: u64::from(sync.page_advanced() || cost.outputs_walked > 0),
5846 pages: sync.pages(),
5847 reached_tip: sync.reached_tip() && cursor >= sync.completed_cursor(),
5848 cursor,
5849 cost,
5850 },
5851 );
5852 }
5853 }
5854
5855 topics_synced.insert(
5856 topic.clone(),
5857 TopicSyncResult {
5858 cost: crate::meter::snapshot().since(cost_before),
5859 root_bounds,
5860 peers_skipped,
5861 peers_filtered,
5862 roots_proven,
5863 roots_unproven,
5864 cursor_held_by,
5865 expired_refused,
5866 peers,
5867 sync_type,
5868 errors,
5869 pruned_inputs,
5870 discarded_graphs,
5871 anchor_walk_held_graphs,
5872 finalized_graphs,
5873 deadline_dropped_graphs,
5874 cursor_moves,
5875 deferred_graphs,
5876 resumed_graphs,
5877 converged_graphs,
5878 dropped_graphs,
5879 stalled_graphs,
5880 held_back_graphs,
5881 backed_off_graphs,
5882 waiting_graphs,
5883 idle_counted,
5884 peer_results,
5885 dead_graphs,
5886 expired_swept,
5887 memo_folded,
5888 unanchorable_graphs,
5889 unanchorable_cleared,
5890 absent_struck,
5891 route_faults,
5892 strikes_refused,
5893 faulted_roots,
5894 },
5895 );
5896 }
5897
5898 info!(
5899 "[GASP SYNC] Peer discovery complete for {} topic(s)",
5900 topics_synced.len()
5901 );
5902
5903 let expired: u64 = topics_synced.values().map(|r| r.expired_refused).sum();
5904 if expired > 0 {
5905 info!("[GASP SYNC] gaspExpiredRefused={expired}");
5906 }
5907 let unanchorable: u64 = topics_synced.values().map(|r| r.unanchorable_graphs).sum();
5908 if unanchorable > 0 {
5909 info!("[GASP SYNC] gaspUnanchorable={unanchorable}");
5910 }
5911 let absent: u64 = topics_synced.values().map(|r| r.absent_struck).sum();
5912 if absent > 0 {
5913 info!("[GASP SYNC] gaspAbsentStruck={absent}");
5914 }
5915 let routes: usize = topics_synced.values().map(|r| r.route_faults.len()).sum();
5916 if routes > 0 {
5917 info!("[GASP SYNC] gaspRouteFaults={routes}");
5918 }
5919 let refused: u64 = topics_synced.values().map(|r| r.strikes_refused).sum();
5920 if refused > 0 {
5921 info!("[GASP SYNC] gaspStrikesRefused={refused}");
5922 }
5923 Ok(GASPSyncResult {
5924 cost: crate::meter::snapshot(),
5925 topics_synced,
5926 filter: filter.clone(),
5927 topics_filtered,
5928 })
5929 }
5930
5931 async fn submit_finalized_graphs(
5955 &self,
5956 sink: &crate::gasp_overlay::FinalizedGraphSink,
5957 peer_url: &str,
5958 topic: &str,
5959 submitted: &std::cell::Cell<u64>,
5960 ) -> bool {
5961 let finalized: Vec<_> = sink
5962 .lock()
5963 .unwrap_or_else(std::sync::PoisonError::into_inner)
5964 .drain(..)
5965 .collect();
5966
5967 let mut all_landed = true;
5968 for graph in &finalized {
5969 for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5970 self.finalize_gate.stop_if_due().await;
5971 if position == 0 {
5972 submitted.set(submitted.get() + 1);
5973 }
5974 let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5975 let outcome = {
5985 let _writing = self.finalize_gate.write_section();
5986 let bound = self
5987 .finalize_submit_budget
5988 .as_ref()
5989 .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5990 self.submit_bounded(
5991 &tagged,
5992 SubmitMode::HistoricalTxNoSpv,
5993 bound.as_ref(),
5994 true,
5995 WalkBreachPolicy::NotNow,
5996 )
5997 .await
5998 };
5999 let not_landed = match outcome {
6000 Ok((_, report)) => {
6001 let landed = report.is_durable()
6002 && (report.applied_topics.contains(&graph.topic)
6003 || report.deduped_topics.contains(&graph.topic));
6004 (!landed).then(|| {
6005 if report.is_durable() {
6006 "the topic manager failed on it".to_string()
6007 } else {
6008 format!("not durable: {}", report.summary())
6009 }
6010 })
6011 }
6012 Err(e) => Some(e.to_string()),
6013 };
6014 if let Some(why) = not_landed {
6015 warn!(
6016 "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
6017 graph.topic
6018 );
6019 all_landed = false;
6020 break;
6021 }
6022 }
6023 }
6024
6025 if !finalized.is_empty() {
6026 info!(
6027 "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
6028 finalized.len()
6029 );
6030 }
6031 all_landed
6032 }
6033
6034 pub async fn rerequest_held_proofs(
6040 &self,
6041 candidates: &[crate::proof_window::HeldOutput],
6042 progress: &std::cell::Cell<crate::proof_window::PeerProofSummary>,
6043 ) {
6044 use crate::proof_window::{PEER_PROOF_REQUEST_MS, PEER_PROOF_ROW_LIMIT};
6045 let Some(factory) = &self.gasp_remote_factory else {
6046 return;
6047 };
6048 let Some(tracker) = self.chain_tracker.as_deref() else {
6049 return;
6050 };
6051 let mut lookup_hosts = None;
6052 let mut topic_peers = HashMap::new();
6053 let mut landed = HashSet::new();
6054 for candidate in candidates.iter().take(PEER_PROOF_ROW_LIMIT) {
6055 if !self.managers.contains_key(&candidate.topic) {
6056 continue;
6057 }
6058 if let std::collections::hash_map::Entry::Vacant(entry) =
6059 topic_peers.entry(candidate.topic.clone())
6060 {
6061 let peers = match self.config.sync_configuration.get(&candidate.topic) {
6062 Some(SyncTarget::Ship) => {
6063 self.discover_ship_peers(&candidate.topic, &mut lookup_hosts)
6064 .await
6065 }
6066 Some(SyncTarget::Peers(peers)) => self.ship_peers_excluding_self(peers.clone()),
6067 _ => Vec::new(),
6068 };
6069 entry.insert(peers);
6070 }
6071 for peer in &topic_peers[&candidate.topic] {
6072 let remote = factory.create_remote(peer, &candidate.topic);
6073 let graph_id = format!("{}.{}", candidate.txid, candidate.output_index);
6074 let mut summary = progress.get();
6075 summary.requests += 1;
6076 progress.set(summary);
6077 let ask =
6078 remote.request_node(&graph_id, &candidate.txid, candidate.output_index, false);
6079 let response = if let Some((sleep, _)) = &self.peer_sync_budget {
6080 crate::gasp::race_or_deadline(ask, sleep(PEER_PROOF_REQUEST_MS)).await
6081 } else {
6082 Some(ask.await)
6083 };
6084 let Some(Ok(node)) = response else {
6085 let mut summary = progress.get();
6086 summary.faults += 1;
6087 progress.set(summary);
6088 continue;
6089 };
6090 if landed.contains(&candidate.txid) {
6091 continue;
6092 }
6093 let Some(proof_hex) = node.proof else {
6094 continue;
6095 };
6096 let Ok(mut proof) = beef_limits::merkle_path_from_hex(
6097 &proof_hex,
6098 beef_limits::COURIER_PROOF_MAX_BYTES,
6099 ) else {
6100 let mut summary = progress.get();
6101 summary.refused += 1;
6102 progress.set(summary);
6103 continue;
6104 };
6105 let valid = match proof.compute_root(Some(&candidate.txid)) {
6106 Ok(root) => tracker
6107 .is_valid_root_for_height(&root, proof.block_height)
6108 .await
6109 .unwrap_or(false),
6110 Err(_) => false,
6111 };
6112 if !valid {
6113 let mut summary = progress.get();
6114 summary.refused += 1;
6115 progress.set(summary);
6116 continue;
6117 }
6118 for leaf in &mut proof.path[0] {
6121 if leaf.hash.as_deref() == Some(&candidate.txid) {
6122 leaf.txid = true;
6123 }
6124 }
6125 match self.land_peer_proof(candidate, &proof).await {
6126 Ok(()) => {
6127 landed.insert(candidate.txid.clone());
6128 let mut summary = progress.get();
6129 summary.landed += 1;
6130 progress.set(summary);
6131 }
6132 Err(error) => {
6133 warn!(%error, "[proof window] verified proof could not be stored");
6134 let mut summary = progress.get();
6135 summary.faults += 1;
6136 progress.set(summary);
6137 }
6138 }
6139 }
6140 }
6141 }
6142
6143 async fn land_peer_proof(
6144 &self,
6145 candidate: &crate::proof_window::HeldOutput,
6146 proof: &bsv_rs::transaction::MerklePath,
6147 ) -> Result<(), EngineError> {
6148 let output = self
6149 .storage
6150 .find_output(
6151 &candidate.txid,
6152 candidate.output_index,
6153 Some(&candidate.topic),
6154 None,
6155 true,
6156 )
6157 .await
6158 .map_err(|e| EngineError::StorageError(e.to_string()))?
6159 .ok_or(EngineError::NodeNotFound)?;
6160 let beef = output
6161 .beef
6162 .ok_or_else(|| EngineError::BeefParseError("missing stored BEEF".into()))?;
6163 let stitched = Self::stitch_proof_into_stored_beef(&beef, &candidate.txid, proof)
6164 .ok_or_else(|| {
6165 EngineError::BeefParseError("proof does not stitch into stored subject".into())
6166 })?;
6167 self.storage
6168 .update_transaction_beef(&candidate.txid, &stitched)
6169 .await
6170 .map_err(|e| EngineError::StorageError(e.to_string()))?;
6171 self.storage
6172 .mark_transaction_proven_at(&candidate.txid, Some(u64::from(proof.block_height)))
6173 .await
6174 .map_err(|e| EngineError::StorageError(e.to_string()))
6175 }
6176
6177 async fn peer_advertisement_versions(&self, topic: &str) -> HashMap<String, String> {
6180 let mut versions: HashMap<String, Vec<String>> = HashMap::new();
6181 for (ad_topic, protocol, scope) in [
6182 ("tm_slap", &b"SLAP"[..], None),
6183 ("tm_ship", &b"SHIP"[..], Some(topic)),
6184 ] {
6185 let rows = self
6186 .storage
6187 .find_utxos_for_topic(ad_topic, None, None, false)
6188 .await
6189 .unwrap_or_default();
6190 for row in rows {
6191 if let Some(domain) =
6192 parse_advertisement_domain_from_script(&row.output_script, protocol, scope)
6193 {
6194 versions
6195 .entry(crate::gasp::peer_origin(&domain))
6196 .or_default()
6197 .push(format!("{}:{}", row.txid, row.output_index));
6198 }
6199 }
6200 }
6201 versions
6202 .into_iter()
6203 .map(|(peer, mut keys)| {
6204 keys.sort();
6205 (peer, keys.join(","))
6206 })
6207 .collect()
6208 }
6209
6210 async fn discover_ship_peers(
6211 &self,
6212 topic: &str,
6213 lookup_hosts: &mut Option<Vec<String>>,
6214 ) -> Vec<String> {
6215 let mut domains = self.local_ship_peers(topic).await;
6216 if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
6217 domains.extend(bootstrap.iter().cloned());
6218 }
6219 if let Some(client) = &self.ship_lookup_client {
6220 if lookup_hosts.is_none() {
6221 *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
6222 }
6223 let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
6224 for host in lookup_hosts.as_ref().into_iter().flatten() {
6225 crate::meter::peer_request();
6226 match client.lookup(host, &question).await {
6227 Ok(outputs) => {
6228 domains.extend(outputs.iter().filter_map(|output| {
6229 parse_lookup_advertisement(output, b"SHIP", topic)
6230 }));
6231 }
6232 Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
6233 }
6234 }
6235 }
6236 self.ship_peers_excluding_self(domains)
6237 }
6238
6239 async fn discover_ship_lookup_hosts(
6240 &self,
6241 client: &dyn crate::ship_lookup::ShipLookupClient,
6242 ) -> Vec<String> {
6243 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
6244 let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
6245 let tracker_origins: HashSet<_> = trackers
6246 .iter()
6247 .map(|host| crate::gasp::peer_origin(host))
6248 .collect();
6249 let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
6250 let mut advertised = Vec::new();
6251 for tracker in &trackers {
6252 crate::meter::peer_request();
6253 match client.lookup(tracker, &question).await {
6254 Ok(outputs) => {
6255 advertised.extend(outputs.iter().filter_map(|output| {
6256 parse_lookup_advertisement(output, b"SLAP", "ls_ship")
6257 }));
6258 }
6259 Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
6260 }
6261 }
6262 let providers = self
6263 .ship_peers_excluding_self(advertised)
6264 .into_iter()
6265 .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
6266 .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
6267 trackers.into_iter().chain(providers).collect()
6268 }
6269
6270 fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
6271 crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
6272 self.config.hosting_url.as_ref().is_none_or(|our_url| {
6273 crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
6274 })
6275 }))
6276 }
6277
6278 async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
6280 let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
6281 warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
6282 return Vec::new();
6283 };
6284
6285 let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
6286
6287 let refs = match ship_ls.lookup(&question).await {
6288 Ok(LookupResult::OutputList(refs)) => refs,
6289 Ok(LookupResult::Answer(_)) => {
6290 error!(
6291 "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
6292 LookupAnswer; expected OutputList. Skipping."
6293 );
6294 return Vec::new();
6295 }
6296 Err(e) => {
6297 error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
6298 return Vec::new();
6299 }
6300 };
6301
6302 let mut domains = HashSet::new();
6303 for reference in &refs {
6304 if let Ok(Some(output)) = self
6305 .storage
6306 .find_output(&reference.txid, reference.output_index, None, None, false)
6307 .await
6308 {
6309 if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
6310 if let Some(ref our_url) = self.config.hosting_url {
6314 if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
6315 || crate::gasp::peer_origin(&domain)
6316 == crate::gasp::peer_origin(our_url)
6317 {
6318 continue;
6319 }
6320 }
6321 domains.insert(domain);
6322 }
6323 }
6324 }
6325
6326 crate::gasp::ship_peers_by_origin(domains)
6330 }
6331
6332 pub fn storage(&self) -> &dyn Storage {
6338 self.storage.as_ref()
6339 }
6340
6341 pub fn config(&self) -> &EngineConfig {
6343 &self.config
6344 }
6345}
6346
6347pub(crate) async fn verify_spv_like_the_reference(
6364 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6365 verify_scripts: bool,
6366 beef_bytes: &[u8],
6367 subject_txid: &str,
6368 budget: DoorBudget,
6369) -> Result<Option<WalkCouldNotRun>, EngineError> {
6370 verify_spv_trusting(
6371 chain_tracker,
6372 verify_scripts,
6373 beef_bytes,
6374 subject_txid,
6375 &HashSet::new(),
6376 budget,
6377 )
6378 .await
6379}
6380
6381async fn verify_spv_trusting(
6386 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6387 verify_scripts: bool,
6388 beef_bytes: &[u8],
6389 subject_txid: &str,
6390 trusted: &HashSet<String>,
6391 budget: DoorBudget,
6392) -> Result<Option<WalkCouldNotRun>, EngineError> {
6393 if verify_scripts {
6394 Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
6395 } else {
6396 Engine::verify_spv_structurally(chain_tracker, beef_bytes)
6397 .await
6398 .map(|()| None)
6399 }
6400}
6401
6402fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
6406 let mut proven: HashMap<&str, usize> = HashMap::new();
6407 for (at, bump) in beef.bumps.iter().enumerate() {
6408 for leaf in bump.path.first().into_iter().flatten() {
6409 if let Some(hash) = leaf.hash.as_deref() {
6410 proven.entry(hash).or_insert(at);
6411 }
6412 }
6413 }
6414 proven
6415}
6416
6417fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
6422 let bodies: HashMap<String, &Transaction> = beef
6423 .txs
6424 .iter()
6425 .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
6426 .collect();
6427 let proven = proven_index(beef);
6428 let mut cover = HashSet::new();
6429 let mut queue = vec![from.to_string()];
6430 while let Some(txid) = queue.pop() {
6431 if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
6432 continue;
6433 }
6434 if let Some(tx) = bodies.get(&txid) {
6435 queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
6436 }
6437 }
6438 cover
6439}
6440
6441struct SubmitAsFinalized<'e> {
6444 engine: &'e Engine,
6445 sink: crate::gasp_overlay::FinalizedGraphSink,
6446 peer_url: &'e str,
6447 topic: &'e str,
6448 submitted: &'e std::cell::Cell<u64>,
6449}
6450
6451#[async_trait::async_trait(?Send)]
6452impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
6453 async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
6454 let landed = self
6458 .engine
6459 .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
6460 .await;
6461 if landed {
6462 Ok(())
6463 } else {
6464 Err(crate::gasp::GASPError::StorageError(
6467 "a finalize submit did not land".to_string(),
6468 ))
6469 }
6470 }
6471}
6472
6473#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6477pub struct GASPSyncResult {
6478 #[serde(default, flatten)]
6479 pub cost: crate::meter::SyncCost,
6480 #[serde(default)]
6481 pub filter: crate::sync_load::SyncFilter,
6482 #[serde(default)]
6483 pub topics_filtered: Vec<String>,
6484 pub topics_synced: HashMap<String, TopicSyncResult>,
6486}
6487
6488#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6490pub struct TopicSyncResult {
6491 #[serde(default)]
6492 pub peer_results: HashMap<String, crate::backfill::PeerSyncResult>,
6493 #[serde(default)]
6494 pub dead_graphs: u64,
6495 #[serde(default)]
6496 pub expired_swept: u64,
6497 #[serde(default)]
6498 pub memo_folded: u64,
6499 #[serde(default, flatten)]
6500 pub cost: crate::meter::SyncCost,
6501 #[serde(default)]
6502 pub root_bounds: Vec<crate::sync_load::RootBound>,
6503 #[serde(default)]
6504 pub peers_skipped: Vec<crate::sync_load::SkippedPeer>,
6505 #[serde(default)]
6506 pub peers_filtered: Vec<String>,
6507 #[serde(default)]
6509 pub roots_proven: u64,
6510 #[serde(default)]
6511 pub roots_unproven: u64,
6512 #[serde(default)]
6514 pub cursor_held_by: Vec<String>,
6515 #[serde(default)]
6517 pub expired_refused: u64,
6518 pub peers: Vec<String>,
6520 pub sync_type: String,
6522 pub errors: Vec<String>,
6528 #[serde(default)]
6534 pub pruned_inputs: u64,
6535 #[serde(default)]
6540 pub discarded_graphs: u64,
6541 #[serde(default)]
6547 pub anchor_walk_held_graphs: u64,
6548 #[serde(default)]
6558 pub finalized_graphs: u64,
6559 #[serde(default)]
6567 pub deadline_dropped_graphs: u64,
6568 #[serde(default)]
6573 pub cursor_moves: Vec<CursorMove>,
6574 #[serde(default)]
6578 pub deferred_graphs: u64,
6579 #[serde(default)]
6581 pub resumed_graphs: u64,
6582 #[serde(default)]
6585 pub converged_graphs: u64,
6586 #[serde(default)]
6588 pub dropped_graphs: Vec<DroppedDeferral>,
6589 #[serde(default)]
6594 pub stalled_graphs: u64,
6595 #[serde(default)]
6599 pub held_back_graphs: u64,
6600 #[serde(default)]
6605 pub backed_off_graphs: u64,
6606 #[serde(default)]
6609 pub waiting_graphs: u64,
6610 #[serde(default)]
6615 pub idle_counted: u64,
6616 #[serde(default)]
6622 pub unanchorable_graphs: u64,
6623 #[serde(default)]
6629 pub absent_struck: u64,
6630 #[serde(default)]
6635 pub route_faults: Vec<crate::route_fault::RouteFaultSeen>,
6636 #[serde(default)]
6639 pub unanchorable_cleared: u64,
6640 #[serde(default)]
6645 pub strikes_refused: u64,
6646 #[serde(default)]
6652 pub faulted_roots: Vec<crate::gasp::FaultedRoot>,
6653}
6654
6655#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6658pub struct DroppedDeferral {
6659 pub peer: String,
6661 pub outpoint: String,
6663 pub reason: String,
6665}
6666
6667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6670pub struct CursorMove {
6671 pub peer: String,
6673 pub from: u64,
6675 pub to: u64,
6677}
6678
6679pub(crate) fn current_timestamp_ms() -> f64 {
6684 #[cfg(target_arch = "wasm32")]
6685 {
6686 js_sys::Date::now()
6687 }
6688 #[cfg(not(target_arch = "wasm32"))]
6689 {
6690 std::time::SystemTime::now()
6691 .duration_since(std::time::UNIX_EPOCH)
6692 .map_or(0.0, |d| d.as_millis() as f64)
6693 }
6694}
6695
6696fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
6702 parse_advertisement_domain_from_script(output_script, b"SHIP", None)
6703}
6704
6705fn parse_lookup_advertisement(
6706 output: &OutputListItem,
6707 protocol: &[u8],
6708 scope: &str,
6709) -> Option<String> {
6710 let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
6711 .ok()?;
6712 let script = tx
6713 .outputs
6714 .get(output.output_index as usize)?
6715 .locking_script
6716 .to_binary();
6717 parse_advertisement_domain_from_script(&script, protocol, Some(scope))
6718}
6719
6720fn parse_advertisement_domain_from_script(
6721 output_script: &[u8],
6722 protocol: &[u8],
6723 scope: Option<&str>,
6724) -> Option<String> {
6725 use bsv_rs::script::templates::PushDrop;
6726
6727 let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
6728 let pushdrop = PushDrop::decode(&script.into()).ok()?;
6729
6730 if pushdrop.fields.len() < 3 {
6731 return None;
6732 }
6733
6734 if pushdrop.fields[0] != protocol {
6735 return None;
6736 }
6737
6738 if scope.is_some_and(|expected| {
6739 pushdrop
6740 .fields
6741 .get(3)
6742 .is_none_or(|actual| actual != expected.as_bytes())
6743 }) {
6744 return None;
6745 }
6746
6747 let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
6748 if domain.is_empty() {
6749 return None;
6750 }
6751
6752 Some(domain)
6753}
6754
6755#[derive(Debug, thiserror::Error)]
6764pub enum EngineError {
6765 #[error("This server does not support this topic: {0}")]
6766 UnsupportedTopic(String),
6767
6768 #[error("Lookup service not found for provider: {0}")]
6769 LookupServiceNotFound(String),
6770
6771 #[error("No matching output found!")]
6774 NodeNotFound,
6775
6776 #[error("lookup failed: {0}")]
6777 LookupFailed(String),
6778
6779 #[error("invalid query: {0}")]
6786 InvalidQuery(String),
6787
6788 #[error("storage error: {0}")]
6789 StorageError(String),
6790
6791 #[error("broadcast error: {0}")]
6792 BroadcastError(String),
6793
6794 #[error("SPV verification failed: {0}")]
6795 SpvError(String),
6796
6797 #[error("BEEF parsing failed: {0}")]
6798 BeefParseError(String),
6799
6800 #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6810 ScriptVerificationFailed {
6811 subject_txid: String,
6812 input_index: u32,
6813 reason: String,
6814 },
6815
6816 #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6823 ScriptWalkInconclusive {
6824 at_txid: String,
6825 subject_judged: bool,
6826 reason: String,
6827 },
6828
6829 #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6834 ScriptWalkOverBudget {
6835 at_txid: String,
6836 subject_judged: bool,
6837 limb: DoorLimb,
6838 what: String,
6839 },
6840
6841 #[error(
6851 "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6852 .0.limb.as_str(),
6853 .0.what
6854 )]
6855 WalkCouldNotRun(WalkCouldNotRun),
6856
6857 #[error("{0}")]
6858 Other(String),
6859}
6860
6861impl From<StorageError> for EngineError {
6862 fn from(e: StorageError) -> Self {
6863 EngineError::StorageError(e.to_string())
6864 }
6865}
6866
6867impl From<TopicManagerError> for EngineError {
6868 fn from(e: TopicManagerError) -> Self {
6869 EngineError::Other(e.to_string())
6870 }
6871}
6872
6873impl From<LookupServiceError> for EngineError {
6874 fn from(e: LookupServiceError) -> Self {
6875 EngineError::LookupFailed(e.to_string())
6876 }
6877}
6878
6879impl From<AdvertiserError> for EngineError {
6880 fn from(e: AdvertiserError) -> Self {
6881 EngineError::Other(e.to_string())
6882 }
6883}
6884
6885pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6895 let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6896 order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6897 order
6898}
6899
6900#[cfg(test)]
6901mod tests {
6902 use super::*;
6903 use crate::lookup_service::LookupService as LookupServiceTrait;
6904 use crate::storage::memory::MemoryStorage;
6905 use crate::topic_manager::TopicManager as TopicManagerTrait;
6906 use async_trait::async_trait;
6907 use std::sync::Mutex;
6908
6909 #[test]
6913 fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6914 let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6915 let config: SyncConfiguration = HashMap::from([
6916 ("tm_a".to_string(), SyncTarget::Ship),
6917 ("tm_z".to_string(), peers("https://z")),
6918 ("tm_b".to_string(), SyncTarget::Ship),
6919 ("tm_m".to_string(), peers("https://m")),
6920 ("tm_off".to_string(), SyncTarget::Disabled),
6921 ]);
6922 let order: Vec<&str> = sync_order(&config)
6923 .iter()
6924 .map(|(t, _)| t.as_str())
6925 .collect();
6926 assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6927 }
6928
6929 struct MockTopicManager {
6932 admit_indices: Vec<u32>,
6933 }
6934
6935 impl MockTopicManager {
6936 fn admitting(indices: Vec<u32>) -> Self {
6937 Self {
6938 admit_indices: indices,
6939 }
6940 }
6941 }
6942
6943 #[async_trait(?Send)]
6944 impl TopicManagerTrait for MockTopicManager {
6945 async fn identify_admissible_outputs(
6946 &self,
6947 _tx: &Transaction,
6948 _previous_coins: &[u8],
6949 _off_chain_values: Option<&[u8]>,
6950 _mode: SubmitMode,
6951 _context: &TopicAdmittanceContext,
6952 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6953 Ok(AdmittanceInstructions {
6954 outputs_to_admit: self.admit_indices.clone(),
6955 coins_to_retain: vec![],
6956 coins_removed: None,
6957 })
6958 }
6959
6960 async fn get_documentation(&self) -> String {
6961 "Mock topic manager".to_string()
6962 }
6963
6964 async fn get_metadata(&self) -> ServiceMetadata {
6965 ServiceMetadata {
6966 name: "mock-tm".to_string(),
6967 description: Some("Mock for testing".to_string()),
6968 ..Default::default()
6969 }
6970 }
6971 }
6972
6973 struct MockLookupService {
6976 records: Mutex<Vec<UTXOReference>>,
6977 }
6978
6979 impl MockLookupService {
6980 fn new() -> Self {
6981 Self {
6982 records: Mutex::new(Vec::new()),
6983 }
6984 }
6985 }
6986
6987 #[async_trait(?Send)]
6988 impl LookupServiceTrait for MockLookupService {
6989 fn admission_mode(&self) -> AdmissionMode {
6990 AdmissionMode::LockingScript
6991 }
6992
6993 fn spend_notification_mode(&self) -> SpendNotificationMode {
6994 SpendNotificationMode::None
6995 }
6996
6997 async fn output_admitted_by_topic(
6998 &self,
6999 payload: &OutputAdmittedByTopic,
7000 ) -> Result<(), LookupServiceError> {
7001 let (txid, oi) = match payload {
7002 OutputAdmittedByTopic::LockingScript {
7003 txid, output_index, ..
7004 } => (txid.clone(), *output_index),
7005 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
7006 ("whole".into(), *output_index)
7007 }
7008 };
7009 self.records.lock().unwrap().push(UTXOReference {
7010 txid,
7011 output_index: oi,
7012 });
7013 Ok(())
7014 }
7015
7016 async fn output_evicted(
7017 &self,
7018 txid: &str,
7019 output_index: u32,
7020 ) -> Result<(), LookupServiceError> {
7021 self.records
7022 .lock()
7023 .unwrap()
7024 .retain(|r| !(r.txid == txid && r.output_index == output_index));
7025 Ok(())
7026 }
7027
7028 async fn lookup(
7029 &self,
7030 _question: &LookupQuestion,
7031 ) -> Result<LookupResult, LookupServiceError> {
7032 Ok(LookupResult::OutputList(
7033 self.records.lock().unwrap().clone(),
7034 ))
7035 }
7036
7037 async fn get_documentation(&self) -> String {
7038 "Mock lookup service".to_string()
7039 }
7040
7041 async fn get_metadata(&self) -> ServiceMetadata {
7042 ServiceMetadata {
7043 name: "mock-ls".to_string(),
7044 ..Default::default()
7045 }
7046 }
7047 }
7048
7049 const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
7053 const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
7054
7055 fn decode_hex(hex: &str) -> Vec<u8> {
7056 (0..hex.len())
7057 .step_by(2)
7058 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
7059 .collect()
7060 }
7061
7062 fn test_beef() -> Vec<u8> {
7063 decode_hex(TEST_BEEF_HEX)
7064 }
7065
7066 fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
7067 TaggedBEEF::new(
7068 test_beef(),
7069 topics.into_iter().map(str::to_string).collect(),
7070 )
7071 }
7072
7073 fn make_engine(admit_indices: Vec<u32>) -> Engine {
7076 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7077 managers.insert(
7078 "tm_test".to_string(),
7079 Box::new(MockTopicManager::admitting(admit_indices)),
7080 );
7081
7082 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7083 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7084
7085 let storage = Box::new(MemoryStorage::new());
7086
7087 Engine::new(
7088 managers,
7089 lookup_services,
7090 storage,
7091 None,
7092 EngineConfig::default(),
7093 )
7094 }
7095
7096 #[tokio::test]
7105 async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
7106 use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
7107 use std::cell::RefCell;
7108 use std::rc::Rc;
7109
7110 let parsed = Beef::from_binary(&test_beef()).unwrap();
7113 let parent = parsed.txs[0].tx().unwrap().clone();
7114 let child = parsed.txs[1].tx().unwrap().clone();
7115 let child_id = child.id();
7116 let parent_id = parent.id();
7117 let mut beef = Beef::new();
7118 for bump in &parsed.bumps {
7124 beef.merge_bump(bump.clone());
7125 }
7126 beef.merge_transaction(child);
7127 beef.merge_transaction(parent);
7128 let mut w = bsv_rs::primitives::encoding::Writer::new();
7129 w.write_u32_le(ATOMIC_BEEF);
7130 let mut le = hex::decode(&child_id).unwrap();
7131 le.reverse();
7132 w.write_bytes(&le);
7133 beef.to_writer(&mut w);
7134 let atomic_wire_order = w.into_bytes();
7135
7136 struct RecordingTm(Rc<RefCell<Option<String>>>);
7138 #[async_trait(?Send)]
7139 impl TopicManagerTrait for RecordingTm {
7140 async fn identify_admissible_outputs(
7141 &self,
7142 tx: &Transaction,
7143 _previous_coins: &[u8],
7144 _off_chain_values: Option<&[u8]>,
7145 _mode: SubmitMode,
7146 _context: &TopicAdmittanceContext,
7147 ) -> Result<AdmittanceInstructions, TopicManagerError> {
7148 *self.0.borrow_mut() = Some(tx.id());
7149 Ok(AdmittanceInstructions {
7150 outputs_to_admit: vec![0],
7151 coins_to_retain: vec![],
7152 coins_removed: None,
7153 })
7154 }
7155 async fn get_documentation(&self) -> String {
7156 String::new()
7157 }
7158 async fn get_metadata(&self) -> ServiceMetadata {
7159 ServiceMetadata {
7160 name: "tm_test".to_string(),
7161 ..Default::default()
7162 }
7163 }
7164 }
7165
7166 let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
7167 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7168 managers.insert(
7169 "tm_test".to_string(),
7170 Box::new(RecordingTm(Rc::clone(&seen))),
7171 );
7172 let engine = Engine::new(
7173 managers,
7174 HashMap::new(),
7175 Box::new(MemoryStorage::new()),
7176 None,
7177 EngineConfig::default(),
7178 );
7179
7180 let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
7181 let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
7182
7183 let handed = seen.borrow().clone().expect("TM was not invoked");
7184 assert_eq!(
7185 handed, child_id,
7186 "TM must be handed the atomic subject, got the wire-order pick"
7187 );
7188 assert_ne!(handed, parent_id);
7189 }
7190
7191 #[tokio::test]
7204 async fn history_hydration_preserves_the_stored_proof() {
7205 use bsv_rs::transaction::Beef;
7206
7207 let engine = make_engine(vec![0]);
7208 let stored = test_beef();
7209 let bumps_before = Beef::from_binary(&stored)
7210 .expect("fixture BEEF parses")
7211 .bumps
7212 .len();
7213 assert!(
7214 bumps_before > 0,
7215 "fixture must actually carry a proof, or this cell proves nothing"
7216 );
7217
7218 let output = Output {
7219 txid: TEST_TXID.to_string(),
7220 output_index: 0,
7221 output_script: vec![0x76, 0xa9],
7222 satoshis: 1000,
7223 topic: "tm_test".to_string(),
7224 spent: false,
7225 outputs_consumed: vec![],
7228 consumed_by: vec![],
7229 beef: Some(stored.clone()),
7230 block_height: None,
7231 score: Some(1000.0),
7232 };
7233
7234 let hydrated = engine
7235 .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
7236 .await
7237 .expect("hydration must not error")
7238 .expect("hydration must return the output");
7239 let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
7240 .expect("hydrated BEEF parses");
7241
7242 assert_eq!(
7243 beef.bumps.len(),
7244 bumps_before,
7245 "asking for history must not strip the merkle proof (issue #4)"
7246 );
7247 }
7248
7249 #[tokio::test]
7250 async fn test_submit_unsupported_topic_errors() {
7251 let engine = make_engine(vec![0]);
7252 let beef = test_tagged_beef(vec!["tm_nonexistent"]);
7253
7254 let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
7255 assert!(result.is_err());
7256 assert!(matches!(
7257 result.unwrap_err(),
7258 EngineError::UnsupportedTopic(_)
7259 ));
7260 }
7261
7262 #[tokio::test]
7263 async fn test_submit_admits_outputs() {
7264 let engine = make_engine(vec![0, 1]);
7265 let beef = test_tagged_beef(vec!["tm_test"]);
7266
7267 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7268
7269 let instructions = steak.get("tm_test").unwrap();
7271 assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
7272
7273 let txid = TEST_TXID;
7275 let found = engine
7276 .storage()
7277 .find_output(txid, 0, Some("tm_test"), None, true)
7278 .await
7279 .unwrap()
7280 .expect("Output 0 should be in storage");
7281
7282 assert_eq!(found.txid, TEST_TXID);
7283 assert_eq!(found.satoshis, 26172); assert!(
7285 !found.output_script.is_empty(),
7286 "Script should be populated"
7287 );
7288 assert!(found.beef.is_some(), "BEEF should be stored");
7289 assert!(!found.spent);
7290 }
7291
7292 #[tokio::test]
7293 async fn test_submit_duplicate_is_skipped() {
7294 let engine = make_engine(vec![0]);
7295 let beef = test_tagged_beef(vec!["tm_test"]);
7296
7297 let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7299 assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
7300
7301 let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7303 assert!(steak2["tm_test"].outputs_to_admit.is_empty());
7304 }
7305
7306 #[tokio::test]
7307 async fn test_submit_notifies_lookup_service() {
7308 let engine = make_engine(vec![0]);
7309 let beef = test_tagged_beef(vec!["tm_test"]);
7310
7311 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7312
7313 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7315 let answer = engine.lookup(&question, None).await.unwrap();
7316
7317 match answer {
7318 LookupAnswer::OutputList { outputs } => {
7319 assert_eq!(outputs.len(), 1);
7320 }
7321 _ => panic!("Expected OutputList"),
7322 }
7323 }
7324
7325 #[tokio::test]
7329 async fn test_lookup_with_txids_aligns_txids_with_outputs() {
7330 let engine = make_engine(vec![0]);
7331 let beef = test_tagged_beef(vec!["tm_test"]);
7332 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7333
7334 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7335 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7336 match answer {
7337 LookupAnswer::OutputList { outputs } => {
7338 assert_eq!(outputs.len(), 1);
7339 assert_eq!(txids.len(), outputs.len(), "one txid per output");
7340 assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
7341 }
7342 _ => panic!("Expected OutputList"),
7343 }
7344 }
7345
7346 #[tokio::test]
7347 async fn proof_window_holds_an_aged_child_with_a_proven_parent() {
7348 let mut engine = make_engine(vec![0]);
7349 let storage = std::rc::Rc::new(MemoryStorage::new());
7350 engine.storage = Box::new(storage.clone());
7351 engine
7352 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7353 .await
7354 .unwrap();
7355 assert!(beef_limits::own_proof(&test_beef(), TEST_TXID)
7356 .unwrap()
7357 .is_none());
7358 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7359 storage.advance_clock((crate::proof_window::PROOFLESS_FLAG_MS / 1000) as u64);
7360 assert_eq!(
7361 engine
7362 .lookup_with_txids(&question, None)
7363 .await
7364 .unwrap()
7365 .1
7366 .len(),
7367 1
7368 );
7369 storage.advance_clock(1);
7370 assert!(
7371 engine
7372 .lookup_with_txids(&question, None)
7373 .await
7374 .unwrap()
7375 .1
7376 .is_empty(),
7377 "an aged unproven child must be held even when its parent is proven"
7378 );
7379 }
7380
7381 #[tokio::test]
7382 async fn proof_window_peer_proof_verifies_then_restores_lookup() {
7383 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7384 let mut engine = make_engine(vec![0]);
7385 let storage = std::rc::Rc::new(MemoryStorage::new());
7386 engine.storage = Box::new(storage.clone());
7387 engine
7388 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7389 .await
7390 .unwrap();
7391 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7392 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7393 let candidate = HeldOutput {
7394 txid: TEST_TXID.into(),
7395 output_index: 0,
7396 topic: "tm_test".into(),
7397 };
7398 let (_, mut remote) = budget_page_engine(1);
7399 remote.node_cost_ms = 0;
7400 remote.nodes = std::rc::Rc::new(HashMap::from([(
7401 TEST_TXID.into(),
7402 crate::types::GASPNode {
7403 graph_id: String::new(),
7404 raw_tx: String::new(),
7405 output_index: 0,
7406 proof: Some(single_leaf_bump_hex(TEST_TXID, 800_000)),
7407 tx_metadata: None,
7408 output_metadata: None,
7409 inputs: None,
7410 },
7411 )]));
7412 engine
7413 .config
7414 .sync_configuration
7415 .insert("tm_test".into(), SyncTarget::Ship);
7416 engine.ship_bootstrap_peers.insert(
7417 "tm_test".into(),
7418 vec!["https://a.example".into(), "https://b.example".into()],
7419 );
7420 engine.set_gasp_remote_factory(Box::new(remote));
7421 let progress = std::cell::Cell::new(PeerProofSummary::default());
7422 engine.chain_tracker = Some(Box::new(bsv_rs::transaction::MockChainTracker::new(
7423 800_001,
7424 )));
7425 engine
7426 .rerequest_held_proofs(std::slice::from_ref(&candidate), &progress)
7427 .await;
7428 assert_eq!(progress.get().requests, 2, "ask every topic peer");
7429 assert_eq!(progress.get().landed, 0);
7430 assert_eq!(progress.get().refused, 2);
7431 assert!(engine
7432 .lookup_with_txids(&question, None)
7433 .await
7434 .unwrap()
7435 .1
7436 .is_empty());
7437 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7438 tracker.add_root(800_000, TEST_TXID.into());
7439 engine.chain_tracker = Some(Box::new(tracker));
7440 engine.rerequest_held_proofs(&[candidate], &progress).await;
7441 assert_eq!(progress.get().requests, 4);
7442 assert_eq!(
7443 progress.get().landed,
7444 1,
7445 "count durable proofs, not peer answers"
7446 );
7447 let (_, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7448 assert_eq!(txids, vec![TEST_TXID]);
7449 let stored = storage
7450 .find_output(TEST_TXID, 0, None, None, true)
7451 .await
7452 .unwrap()
7453 .unwrap();
7454 assert!(
7455 beef_limits::own_proof(stored.beef.as_ref().unwrap(), TEST_TXID)
7456 .unwrap()
7457 .is_some()
7458 );
7459 assert_eq!(storage.proven_at_height(TEST_TXID), Some(Some(800_000)));
7460 }
7461
7462 #[tokio::test]
7463 async fn proof_window_peer_hash_leaf_is_stored_as_own_proof() {
7464 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7465 let mut engine = make_engine(vec![0]);
7466 let storage = std::rc::Rc::new(MemoryStorage::new());
7467 engine.storage = Box::new(storage.clone());
7468 engine
7469 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7470 .await
7471 .unwrap();
7472 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7473 let mut proof =
7474 bsv_rs::transaction::MerklePath::from_hex(&single_leaf_bump_hex(TEST_TXID, 800_000))
7475 .unwrap();
7476 proof.path[0][0].txid = false;
7477 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7478 tracker.add_root(800_000, proof.compute_root(Some(TEST_TXID)).unwrap());
7479 engine.chain_tracker = Some(Box::new(tracker));
7480 let (_, mut remote) = budget_page_engine(1);
7481 remote.node_cost_ms = 0;
7482 remote.nodes = std::rc::Rc::new(HashMap::from([(
7483 TEST_TXID.into(),
7484 crate::types::GASPNode {
7485 graph_id: String::new(),
7486 raw_tx: String::new(),
7487 output_index: 0,
7488 proof: Some(proof.to_hex()),
7489 tx_metadata: None,
7490 output_metadata: None,
7491 inputs: None,
7492 },
7493 )]));
7494 engine.config.sync_configuration.insert(
7495 "tm_test".into(),
7496 SyncTarget::Peers(vec!["https://peer.example".into()]),
7497 );
7498 engine.set_gasp_remote_factory(Box::new(remote));
7499 let progress = std::cell::Cell::new(PeerProofSummary::default());
7500 engine
7501 .rerequest_held_proofs(
7502 &[HeldOutput {
7503 txid: TEST_TXID.into(),
7504 output_index: 0,
7505 topic: "tm_test".into(),
7506 }],
7507 &progress,
7508 )
7509 .await;
7510 assert_eq!(progress.get().landed, 1);
7511 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7512 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7513 assert_eq!(txids, vec![TEST_TXID]);
7514 let LookupAnswer::OutputList { outputs } = answer else {
7515 panic!("expected outputs")
7516 };
7517 assert!(
7518 beef_limits::own_proof(&outputs[0].beef, TEST_TXID)
7519 .unwrap()
7520 .is_some(),
7521 "a proven flag must have the child's own path in the served BEEF"
7522 );
7523 }
7524
7525 #[tokio::test]
7526 async fn proof_window_silent_peers_yield_at_the_request_budget() {
7527 use crate::proof_window::{HeldOutput, PeerProofSummary, PEER_PROOF_REQUEST_MS};
7528 let (mut engine, remote) = budget_page_engine(1);
7529 engine.config.sync_configuration.insert(
7530 "tm_test".into(),
7531 SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
7532 );
7533 let elapsed = remote.elapsed.clone();
7534 engine.set_peer_sync_budget(
7535 std::rc::Rc::new(move |ms| {
7536 assert_eq!(ms, PEER_PROOF_REQUEST_MS);
7537 let deadline = elapsed.get() + ms;
7538 let elapsed = elapsed.clone();
7539 Box::pin(std::future::poll_fn(move |_| {
7540 if elapsed.get() >= deadline {
7541 std::task::Poll::Ready(())
7542 } else {
7543 std::task::Poll::Pending
7544 }
7545 }))
7546 }),
7547 30_000,
7548 );
7549 let candidate = HeldOutput {
7550 txid: remote.outputs[0].txid.clone(),
7551 output_index: 0,
7552 topic: "tm_test".into(),
7553 };
7554 engine.set_gasp_remote_factory(Box::new(remote));
7555 let progress = std::cell::Cell::new(PeerProofSummary::default());
7556 engine.rerequest_held_proofs(&[candidate], &progress).await;
7557 assert_eq!(progress.get().requests, 2);
7558 assert_eq!(progress.get().faults, 2);
7559 assert_eq!(progress.get().landed, 0);
7560 }
7561
7562 #[test]
7564 fn sync_limit_clamp() {
7565 assert_eq!(
7566 Engine::clamp_sync_limit(None),
7567 Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
7568 "absent limit gets the default page, never unbounded"
7569 );
7570 assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
7571 assert_eq!(
7572 Engine::clamp_sync_limit(Some(u64::MAX)),
7573 Engine::SYNC_RESPONSE_MAX_LIMIT,
7574 "a huge caller-supplied limit is capped"
7575 );
7576 }
7577
7578 #[tokio::test]
7579 async fn test_lookup_unknown_service_errors() {
7580 let engine = make_engine(vec![]);
7581 let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
7582
7583 let result = engine.lookup(&question, None).await;
7584 assert!(result.is_err());
7585 assert!(matches!(
7586 result.unwrap_err(),
7587 EngineError::LookupServiceNotFound(_)
7588 ));
7589 }
7590
7591 #[tokio::test]
7592 async fn test_list_topic_managers() {
7593 let engine = make_engine(vec![]);
7594 let managers = engine.list_topic_managers().await;
7595
7596 assert_eq!(managers.len(), 1);
7597 assert!(managers.contains_key("tm_test"));
7598 assert_eq!(managers["tm_test"].name, "mock-tm");
7599 }
7600
7601 #[tokio::test]
7602 async fn test_list_lookup_service_providers() {
7603 let engine = make_engine(vec![]);
7604 let services = engine.list_lookup_service_providers().await;
7605
7606 assert_eq!(services.len(), 1);
7607 assert!(services.contains_key("ls_test"));
7608 assert_eq!(services["ls_test"].name, "mock-ls");
7609 }
7610
7611 #[tokio::test]
7612 async fn test_get_documentation() {
7613 let engine = make_engine(vec![]);
7614
7615 let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
7616 assert_eq!(tm_docs, "Mock topic manager");
7617
7618 let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
7619 assert_eq!(ls_docs, "Mock lookup service");
7620
7621 let missing = engine
7622 .get_documentation_for_topic_manager("tm_missing")
7623 .await;
7624 assert_eq!(missing, "No documentation found!");
7625 }
7626
7627 #[tokio::test]
7628 async fn test_provide_foreign_sync_response() {
7629 let engine = make_engine(vec![0]);
7630
7631 let beef = test_tagged_beef(vec!["tm_test"]);
7633 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7634
7635 let request = GASPInitialRequest {
7637 version: 1,
7638 since: 0,
7639 limit: Some(100),
7640 };
7641 let response = engine
7642 .provide_foreign_sync_response(&request, "tm_test")
7643 .await
7644 .unwrap();
7645
7646 assert_eq!(response.utxo_list.len(), 1);
7648 assert_eq!(response.utxo_list[0].txid, TEST_TXID);
7649 assert_eq!(response.since, 0);
7650 }
7651
7652 #[tokio::test]
7653 async fn test_delete_utxo_deep_simple() {
7654 let engine = make_engine(vec![0]);
7655 let beef = test_tagged_beef(vec!["tm_test"]);
7656
7657 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7658
7659 let txid = TEST_TXID;
7660 let output = engine
7661 .storage()
7662 .find_output(txid, 0, Some("tm_test"), None, false)
7663 .await
7664 .unwrap()
7665 .unwrap();
7666
7667 engine.delete_utxo_deep(&output, None).await.unwrap();
7669
7670 let found = engine
7672 .storage()
7673 .find_output(txid, 0, Some("tm_test"), None, false)
7674 .await
7675 .unwrap();
7676 assert!(found.is_none());
7677 }
7678
7679 #[tokio::test]
7680 async fn test_sync_config_defaults_to_ship() {
7681 let engine = make_engine(vec![]);
7682 let sync = &engine.config.sync_configuration;
7683
7684 assert!(sync.contains_key("tm_test"));
7686 assert!(matches!(sync["tm_test"], SyncTarget::Ship));
7687 }
7688
7689 #[tokio::test]
7690 async fn test_multiple_topics_in_single_submit() {
7691 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7692 managers.insert(
7693 "tm_alpha".to_string(),
7694 Box::new(MockTopicManager::admitting(vec![0])),
7695 );
7696 managers.insert(
7697 "tm_beta".to_string(),
7698 Box::new(MockTopicManager::admitting(vec![1])),
7699 );
7700
7701 let engine = Engine::new(
7702 managers,
7703 HashMap::new(),
7704 Box::new(MemoryStorage::new()),
7705 None,
7706 EngineConfig::default(),
7707 );
7708
7709 let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
7710
7711 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7712
7713 assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
7714 assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
7715 }
7716
7717 #[tokio::test]
7718 async fn test_evict_output_with_topic() {
7719 let engine = make_engine(vec![0]);
7720 let beef = test_tagged_beef(vec!["tm_test"]);
7721 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7722
7723 let found = engine
7725 .storage()
7726 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7727 .await
7728 .unwrap();
7729 assert!(found.is_some(), "Output should exist before eviction");
7730
7731 engine
7733 .evict_output(TEST_TXID, 0, Some("tm_test"))
7734 .await
7735 .unwrap();
7736
7737 let found = engine
7739 .storage()
7740 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7741 .await
7742 .unwrap();
7743 assert!(found.is_none(), "Output should be gone after eviction");
7744
7745 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7747 let answer = engine.lookup(&question, None).await.unwrap();
7748 match answer {
7749 LookupAnswer::OutputList { outputs } => {
7750 assert!(
7751 outputs.is_empty(),
7752 "Lookup should return empty after eviction"
7753 );
7754 }
7755 _ => panic!("Expected OutputList"),
7756 }
7757 }
7758
7759 #[tokio::test]
7760 async fn test_evict_output_without_topic() {
7761 let engine = make_engine(vec![0]);
7762 let beef = test_tagged_beef(vec!["tm_test"]);
7763 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7764
7765 engine.evict_output(TEST_TXID, 0, None).await.unwrap();
7767
7768 let found = engine
7769 .storage()
7770 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7771 .await
7772 .unwrap();
7773 assert!(
7774 found.is_none(),
7775 "Output should be gone after topic-less eviction"
7776 );
7777 }
7778
7779 #[tokio::test]
7780 async fn test_evict_nonexistent_output_is_ok() {
7781 let engine = make_engine(vec![]);
7782
7783 let result = engine
7785 .evict_output("nonexistent_txid", 99, Some("tm_test"))
7786 .await;
7787 assert!(result.is_ok(), "Evicting nonexistent output should succeed");
7788 }
7789
7790 use crate::broadcaster::Broadcaster;
7793 use std::sync::Arc;
7794
7795 type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
7796
7797 struct MockBroadcaster {
7798 calls: BroadcastCallLog,
7799 }
7800
7801 impl MockBroadcaster {
7802 fn new() -> Self {
7803 Self {
7804 calls: Arc::new(Mutex::new(Vec::new())),
7805 }
7806 }
7807
7808 #[allow(dead_code, reason = "kept for future test extension")]
7809 fn call_count(&self) -> usize {
7810 self.calls.lock().unwrap().len()
7811 }
7812
7813 #[allow(dead_code, reason = "kept for future test extension")]
7814 fn calls(&self) -> Vec<(String, Vec<String>)> {
7815 self.calls.lock().unwrap().clone()
7816 }
7817 }
7818
7819 #[async_trait(?Send)]
7820 impl Broadcaster for MockBroadcaster {
7821 async fn broadcast_to_host(
7822 &self,
7823 host_url: &str,
7824 tagged_beef: &TaggedBEEF,
7825 ) -> Result<(), String> {
7826 self.calls
7827 .lock()
7828 .unwrap()
7829 .push((host_url.to_string(), tagged_beef.topics.clone()));
7830 Ok(())
7831 }
7832 }
7833
7834 struct MockSHIPLookupService {
7840 records: Mutex<HashMap<String, Vec<UTXOReference>>>,
7842 }
7843
7844 impl MockSHIPLookupService {
7845 fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
7846 Self {
7847 records: Mutex::new(records),
7848 }
7849 }
7850 }
7851
7852 #[async_trait(?Send)]
7853 impl LookupServiceTrait for MockSHIPLookupService {
7854 fn admission_mode(&self) -> AdmissionMode {
7855 AdmissionMode::LockingScript
7856 }
7857
7858 fn spend_notification_mode(&self) -> SpendNotificationMode {
7859 SpendNotificationMode::None
7860 }
7861
7862 async fn output_admitted_by_topic(
7863 &self,
7864 _payload: &OutputAdmittedByTopic,
7865 ) -> Result<(), LookupServiceError> {
7866 Ok(())
7867 }
7868
7869 async fn output_evicted(
7870 &self,
7871 _txid: &str,
7872 _output_index: u32,
7873 ) -> Result<(), LookupServiceError> {
7874 Ok(())
7875 }
7876
7877 async fn lookup(
7878 &self,
7879 question: &LookupQuestion,
7880 ) -> Result<LookupResult, LookupServiceError> {
7881 if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
7883 let records = self.records.lock().unwrap();
7884 let mut results = Vec::new();
7885 for topic_val in topics {
7886 if let Some(topic) = topic_val.as_str() {
7887 if let Some(refs) = records.get(topic) {
7888 results.extend(refs.iter().cloned());
7889 }
7890 }
7891 }
7892 return Ok(LookupResult::OutputList(results));
7893 }
7894 Ok(LookupResult::OutputList(vec![]))
7895 }
7896
7897 async fn get_documentation(&self) -> String {
7898 "Mock SHIP lookup service".to_string()
7899 }
7900
7901 async fn get_metadata(&self) -> ServiceMetadata {
7902 ServiceMetadata {
7903 name: "mock-ls-ship".to_string(),
7904 ..Default::default()
7905 }
7906 }
7907 }
7908
7909 fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
7911 use bsv_rs::script::templates::PushDrop;
7912 use bsv_rs::PublicKey;
7913
7914 let fields = vec![
7915 b"SHIP".to_vec(),
7916 vec![0x02; 33], domain.as_bytes().to_vec(),
7918 topic.as_bytes().to_vec(),
7919 ];
7920
7921 let pubkey = PublicKey::from_hex(
7924 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7925 )
7926 .expect("valid test pubkey");
7927
7928 let pd = PushDrop::new(pubkey, fields);
7929 pd.lock().to_binary()
7930 }
7931
7932 async fn make_engine_with_broadcaster(
7935 admit_indices: Vec<u32>,
7936 broadcaster: MockBroadcaster,
7937 ship_domains: Vec<(&str, &str)>,
7938 hosting_url: Option<&str>,
7939 ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
7940 let calls = broadcaster.calls.clone();
7941
7942 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7943 managers.insert(
7944 "tm_test".to_string(),
7945 Box::new(MockTopicManager::admitting(admit_indices)),
7946 );
7947
7948 let storage = Box::new(MemoryStorage::new());
7949
7950 let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
7954
7955 for (domain, topic) in &ship_domains {
7956 let fake_txid = format!("{:064x}", {
7958 let mut h: u64 = 0;
7959 for b in domain.bytes().chain(topic.bytes()) {
7960 h = h.wrapping_mul(31).wrapping_add(u64::from(b));
7961 }
7962 h
7963 });
7964
7965 ship_records
7967 .entry(topic.to_string())
7968 .or_default()
7969 .push(UTXOReference {
7970 txid: fake_txid.clone(),
7971 output_index: 0,
7972 });
7973
7974 let script = build_ship_pushdrop_script(domain, topic);
7976
7977 let output = Output {
7978 txid: fake_txid,
7979 output_index: 0,
7980 output_script: script,
7981 satoshis: 1,
7982 topic: "tm_ship".to_string(),
7983 spent: false,
7984 outputs_consumed: vec![],
7985 consumed_by: vec![],
7986 beef: None,
7987 block_height: None,
7988 score: None,
7989 };
7990
7991 storage.insert_output(&output).await.unwrap();
7992 }
7993
7994 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7995 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7996 lookup_services.insert(
7997 "ls_ship".to_string(),
7998 Box::new(MockSHIPLookupService::with_records(ship_records)),
7999 );
8000
8001 let config = EngineConfig {
8002 hosting_url: hosting_url.map(str::to_string),
8003 ..Default::default()
8004 };
8005
8006 let engine = Engine::with_chain_tracker(
8007 managers,
8008 lookup_services,
8009 storage,
8010 None,
8011 Some(Box::new(broadcaster)),
8012 None,
8013 config,
8014 );
8015
8016 (engine, calls)
8017 }
8018
8019 #[tokio::test]
8022 async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
8023 let broadcaster = MockBroadcaster::new();
8024 let (engine, calls) = make_engine_with_broadcaster(
8025 vec![0],
8026 broadcaster,
8027 vec![("https://peer1.example.com", "tm_test")],
8028 Some("https://self.example.com"),
8029 )
8030 .await;
8031
8032 let beef = test_tagged_beef(vec!["tm_test"]);
8033 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8034
8035 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8037
8038 let recorded = calls.lock().unwrap();
8040 assert_eq!(
8041 recorded.len(),
8042 1,
8043 "Broadcaster should be called once for the one SHIP peer"
8044 );
8045 assert_eq!(recorded[0].0, "https://peer1.example.com");
8046 }
8047
8048 #[tokio::test]
8049 async fn test_broadcaster_not_called_on_historical_tx() {
8050 let broadcaster = MockBroadcaster::new();
8051 let (engine, calls) = make_engine_with_broadcaster(
8052 vec![0],
8053 broadcaster,
8054 vec![("https://peer1.example.com", "tm_test")],
8055 None,
8056 )
8057 .await;
8058
8059 let beef = test_tagged_beef(vec!["tm_test"]);
8060 let steak = engine
8061 .submit(&beef, SubmitMode::HistoricalTx)
8062 .await
8063 .unwrap();
8064
8065 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8066
8067 assert_eq!(
8069 calls.lock().unwrap().len(),
8070 0,
8071 "Broadcaster should not be called for historical TX"
8072 );
8073 }
8074
8075 #[tokio::test]
8076 async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
8077 let broadcaster = MockBroadcaster::new();
8078 let (engine, calls) = make_engine_with_broadcaster(
8079 vec![0],
8080 broadcaster,
8081 vec![("https://peer1.example.com", "tm_test")],
8082 None,
8083 )
8084 .await;
8085
8086 let beef = test_tagged_beef(vec!["tm_test"]);
8087 let steak = engine
8088 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
8089 .await
8090 .unwrap();
8091
8092 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8093
8094 assert_eq!(
8095 calls.lock().unwrap().len(),
8096 0,
8097 "Broadcaster should not be called for historical-tx-no-spv"
8098 );
8099 }
8100
8101 #[tokio::test]
8102 async fn test_broadcaster_skips_self_hosting_url() {
8103 let broadcaster = MockBroadcaster::new();
8104 let (engine, calls) = make_engine_with_broadcaster(
8105 vec![0],
8106 broadcaster,
8107 vec![
8108 ("https://self.example.com", "tm_test"),
8109 ("https://peer2.example.com", "tm_test"),
8110 ],
8111 Some("https://self.example.com"),
8112 )
8113 .await;
8114
8115 let beef = test_tagged_beef(vec!["tm_test"]);
8116 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8117
8118 let recorded = calls.lock().unwrap();
8120 assert_eq!(
8121 recorded.len(),
8122 1,
8123 "Should broadcast to peer2 only, skipping self"
8124 );
8125 assert_eq!(recorded[0].0, "https://peer2.example.com");
8126 }
8127
8128 #[tokio::test]
8129 async fn test_broadcaster_not_called_when_no_outputs_admitted() {
8130 let broadcaster = MockBroadcaster::new();
8131 let (engine, calls) = make_engine_with_broadcaster(
8133 vec![],
8134 broadcaster,
8135 vec![("https://peer1.example.com", "tm_test")],
8136 None,
8137 )
8138 .await;
8139
8140 let beef = test_tagged_beef(vec!["tm_test"]);
8141 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8142
8143 assert!(steak["tm_test"].outputs_to_admit.is_empty());
8144
8145 assert_eq!(
8147 calls.lock().unwrap().len(),
8148 0,
8149 "Broadcaster should not be called when no outputs are admitted"
8150 );
8151 }
8152
8153 #[tokio::test]
8154 async fn test_broadcaster_not_called_when_no_ship_peers() {
8155 let broadcaster = MockBroadcaster::new();
8156 let (engine, calls) = make_engine_with_broadcaster(
8158 vec![0],
8159 broadcaster,
8160 vec![], None,
8162 )
8163 .await;
8164
8165 let beef = test_tagged_beef(vec!["tm_test"]);
8166 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8167
8168 assert_eq!(
8169 calls.lock().unwrap().len(),
8170 0,
8171 "Broadcaster should not be called when no SHIP peers exist"
8172 );
8173 }
8174
8175 struct SpendModeLookupService {
8181 mode: SpendNotificationMode,
8182 records: Mutex<Vec<UTXOReference>>,
8183 spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
8184 }
8185
8186 impl SpendModeLookupService {
8187 fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
8188 Self {
8189 mode,
8190 records: Mutex::new(Vec::new()),
8191 spent_payloads: capture,
8192 }
8193 }
8194 }
8195
8196 #[async_trait(?Send)]
8197 impl LookupServiceTrait for SpendModeLookupService {
8198 fn admission_mode(&self) -> AdmissionMode {
8199 AdmissionMode::LockingScript
8200 }
8201
8202 fn spend_notification_mode(&self) -> SpendNotificationMode {
8203 self.mode
8204 }
8205
8206 async fn output_admitted_by_topic(
8207 &self,
8208 payload: &OutputAdmittedByTopic,
8209 ) -> Result<(), LookupServiceError> {
8210 let (txid, oi) = match payload {
8211 OutputAdmittedByTopic::LockingScript {
8212 txid, output_index, ..
8213 } => (txid.clone(), *output_index),
8214 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
8215 ("whole".into(), *output_index)
8216 }
8217 };
8218 self.records.lock().unwrap().push(UTXOReference {
8219 txid,
8220 output_index: oi,
8221 });
8222 Ok(())
8223 }
8224
8225 async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
8226 self.spent_payloads.lock().unwrap().push(payload.clone());
8227 Ok(())
8228 }
8229
8230 async fn output_evicted(
8231 &self,
8232 txid: &str,
8233 output_index: u32,
8234 ) -> Result<(), LookupServiceError> {
8235 self.records
8236 .lock()
8237 .unwrap()
8238 .retain(|r| !(r.txid == txid && r.output_index == output_index));
8239 Ok(())
8240 }
8241
8242 async fn lookup(
8243 &self,
8244 _question: &LookupQuestion,
8245 ) -> Result<LookupResult, LookupServiceError> {
8246 Ok(LookupResult::OutputList(
8247 self.records.lock().unwrap().clone(),
8248 ))
8249 }
8250
8251 async fn get_documentation(&self) -> String {
8252 "SpendMode mock lookup service".to_string()
8253 }
8254
8255 async fn get_metadata(&self) -> ServiceMetadata {
8256 ServiceMetadata {
8257 name: "spend-mode-ls".to_string(),
8258 ..Default::default()
8259 }
8260 }
8261 }
8262
8263 const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
8265
8266 async fn make_spend_mode_engine(
8269 mode: SpendNotificationMode,
8270 ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
8271 let storage = MemoryStorage::new();
8272
8273 let prev_output = Output {
8275 txid: PREVIOUS_TXID.to_string(),
8276 output_index: 0,
8277 output_script: vec![0x76, 0xa9],
8278 satoshis: 26174,
8279 topic: "tm_test".to_string(),
8280 spent: false,
8281 outputs_consumed: vec![],
8282 consumed_by: vec![],
8283 beef: Some(test_beef()),
8284 block_height: None,
8285 score: Some(1000.0),
8286 };
8287 storage.insert_output(&prev_output).await.unwrap();
8288
8289 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8290 managers.insert(
8291 "tm_test".to_string(),
8292 Box::new(MockTopicManager::admitting(vec![0])),
8293 );
8294
8295 let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
8296 let ls = SpendModeLookupService::with_mode(mode, capture.clone());
8297 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8298 lookup_services.insert("ls_test".to_string(), Box::new(ls));
8299
8300 let engine = Engine::new(
8301 managers,
8302 lookup_services,
8303 Box::new(storage),
8304 None,
8305 EngineConfig::default(),
8306 );
8307
8308 (engine, capture)
8309 }
8310
8311 #[tokio::test]
8312 async fn test_spend_notification_mode_script() {
8313 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
8314
8315 let beef = test_tagged_beef(vec!["tm_test"]);
8316 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8317
8318 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8320
8321 let payloads = capture.lock().unwrap().clone();
8323 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8324
8325 match &payloads[0] {
8326 OutputSpent::Script {
8327 txid,
8328 output_index,
8329 topic,
8330 spending_txid,
8331 input_index,
8332 unlocking_script,
8333 sequence_number,
8334 ..
8335 } => {
8336 assert_eq!(txid, PREVIOUS_TXID);
8337 assert_eq!(*output_index, 0);
8338 assert_eq!(topic, "tm_test");
8339 assert_eq!(spending_txid, TEST_TXID);
8340 assert_eq!(*input_index, 0);
8342 assert!(
8344 !unlocking_script.is_empty(),
8345 "Unlocking script should be non-empty"
8346 );
8347 assert_eq!(*sequence_number, 0xffff_ffff);
8349 }
8350 other => panic!("Expected OutputSpent::Script, got: {other:?}"),
8351 }
8352 }
8353
8354 #[tokio::test]
8355 async fn test_spend_notification_mode_whole_tx() {
8356 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
8357
8358 let beef = test_tagged_beef(vec!["tm_test"]);
8359 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8360
8361 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8362
8363 let payloads = capture.lock().unwrap().clone();
8364 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8365
8366 match &payloads[0] {
8367 OutputSpent::WholeTx {
8368 txid,
8369 output_index,
8370 topic,
8371 spending_atomic_beef,
8372 ..
8373 } => {
8374 assert_eq!(txid, PREVIOUS_TXID);
8375 assert_eq!(*output_index, 0);
8376 assert_eq!(topic, "tm_test");
8377 let mut named =
8380 bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
8381 assert!(named.is_atomic());
8382 let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
8383 assert_eq!(
8384 named.txs.len(),
8385 submitted.txs.len(),
8386 "the whole body, never pruned"
8387 );
8388 let tip = crate::subject::subject_txid_of(&mut named).unwrap();
8389 assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
8390 }
8391 other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
8392 }
8393 }
8394
8395 #[tokio::test]
8396 async fn test_spend_notification_mode_txid() {
8397 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
8398
8399 let beef = test_tagged_beef(vec!["tm_test"]);
8400 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8401
8402 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8403
8404 let payloads = capture.lock().unwrap().clone();
8405 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8406
8407 match &payloads[0] {
8408 OutputSpent::Txid {
8409 txid,
8410 output_index,
8411 topic,
8412 spending_txid,
8413 } => {
8414 assert_eq!(txid, PREVIOUS_TXID);
8415 assert_eq!(*output_index, 0);
8416 assert_eq!(topic, "tm_test");
8417 assert_eq!(spending_txid, TEST_TXID);
8418 }
8419 other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
8420 }
8421 }
8422
8423 #[tokio::test]
8424 async fn test_spend_notification_mode_none() {
8425 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
8426
8427 let beef = test_tagged_beef(vec!["tm_test"]);
8428 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8429
8430 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8431
8432 let payloads = capture.lock().unwrap().clone();
8433 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8434
8435 match &payloads[0] {
8436 OutputSpent::None {
8437 txid,
8438 output_index,
8439 topic,
8440 } => {
8441 assert_eq!(txid, PREVIOUS_TXID);
8442 assert_eq!(*output_index, 0);
8443 assert_eq!(topic, "tm_test");
8444 }
8445 other => panic!("Expected OutputSpent::None, got: {other:?}"),
8446 }
8447 }
8448
8449 #[tokio::test]
8452 async fn test_start_gasp_sync_empty_config() {
8453 let engine = Engine::new(
8454 HashMap::new(),
8455 HashMap::new(),
8456 Box::new(MemoryStorage::new()),
8457 None,
8458 EngineConfig {
8459 sync_configuration: HashMap::new(),
8460 ..Default::default()
8461 },
8462 );
8463
8464 let result = engine.start_gasp_sync().await.unwrap();
8465 assert!(
8466 result.topics_synced.is_empty(),
8467 "No topics should be synced with empty config"
8468 );
8469 }
8470
8471 #[tokio::test]
8472 async fn test_start_gasp_sync_disabled_topic_skipped() {
8473 let mut sync_config: SyncConfiguration = HashMap::new();
8474 sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
8475
8476 let engine = Engine::new(
8477 HashMap::new(),
8478 HashMap::new(),
8479 Box::new(MemoryStorage::new()),
8480 None,
8481 EngineConfig {
8482 sync_configuration: sync_config,
8483 ..Default::default()
8484 },
8485 );
8486
8487 let result = engine.start_gasp_sync().await.unwrap();
8488 assert!(
8489 result.topics_synced.is_empty(),
8490 "Disabled topics should not appear in results"
8491 );
8492 }
8493
8494 #[tokio::test]
8495 async fn test_start_gasp_sync_peers_config() {
8496 let mut sync_config: SyncConfiguration = HashMap::new();
8497 sync_config.insert(
8498 "tm_test".to_string(),
8499 SyncTarget::Peers(vec![
8500 "https://peer1.example.com".to_string(),
8501 "https://peer2.example.com".to_string(),
8502 ]),
8503 );
8504
8505 let engine = Engine::new(
8506 HashMap::new(),
8507 HashMap::new(),
8508 Box::new(MemoryStorage::new()),
8509 None,
8510 EngineConfig {
8511 sync_configuration: sync_config,
8512 ..Default::default()
8513 },
8514 );
8515
8516 let result = engine.start_gasp_sync().await.unwrap();
8517 assert_eq!(result.topics_synced.len(), 1);
8518
8519 let topic_result = &result.topics_synced["tm_test"];
8520 assert_eq!(topic_result.peers.len(), 2);
8521 assert_eq!(topic_result.sync_type, "peers");
8522 assert!(topic_result.errors.is_empty());
8523 }
8524
8525 #[tokio::test]
8526 async fn test_start_gasp_sync_peers_filters_self() {
8527 let mut sync_config: SyncConfiguration = HashMap::new();
8528 sync_config.insert(
8529 "tm_test".to_string(),
8530 SyncTarget::Peers(vec![
8531 "https://self.example.com".to_string(),
8532 "https://peer2.example.com".to_string(),
8533 ]),
8534 );
8535
8536 let engine = Engine::new(
8537 HashMap::new(),
8538 HashMap::new(),
8539 Box::new(MemoryStorage::new()),
8540 None,
8541 EngineConfig {
8542 hosting_url: Some("https://self.example.com".to_string()),
8543 sync_configuration: sync_config,
8544 ..Default::default()
8545 },
8546 );
8547
8548 let result = engine.start_gasp_sync().await.unwrap();
8549 let topic_result = &result.topics_synced["tm_test"];
8550 assert_eq!(
8551 topic_result.peers.len(),
8552 1,
8553 "Self URL should be filtered out"
8554 );
8555 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
8556 }
8557
8558 #[tokio::test]
8559 async fn load_0112_quiet_peer_costs_no_request_during_backoff() {
8560 let calls = std::rc::Rc::default();
8561 let store = std::rc::Rc::new(MemoryStorage::new());
8562 let mut engine = Engine::new(
8563 HashMap::new(),
8564 HashMap::new(),
8565 Box::new(store.clone()),
8566 None,
8567 EngineConfig {
8568 sync_configuration: HashMap::from([(
8569 "tm_test".into(),
8570 SyncTarget::Peers(vec!["https://quiet.example".into()]),
8571 )]),
8572 ..Default::default()
8573 },
8574 );
8575 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8576 calls: std::rc::Rc::clone(&calls),
8577 }));
8578 for _ in 0..4 {
8579 engine.start_gasp_sync().await.unwrap();
8580 }
8581 assert_eq!(calls.borrow().len(), 3);
8582 assert_eq!(
8583 store
8584 .get_peer_sync_health("quiet.example", "tm_test")
8585 .await
8586 .unwrap()
8587 .consecutive_failures,
8588 0
8589 );
8590 store.advance_clock(60);
8591 engine.start_gasp_sync().await.unwrap();
8592 assert_eq!(calls.borrow().len(), 4);
8593 engine.start_gasp_sync().await.unwrap();
8594 assert_eq!(calls.borrow().len(), 4);
8595 engine
8596 .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8597 topics: Some(vec!["tm_test".into()]),
8598 peers: None,
8599 })
8600 .await
8601 .unwrap();
8602 assert_eq!(calls.borrow().len(), 5);
8603 engine.start_gasp_sync().await.unwrap();
8604 assert_eq!(
8605 calls.borrow().len(),
8606 6,
8607 "explicit selection reset the quiet streak"
8608 );
8609 }
8610
8611 #[tokio::test]
8612 async fn load_0112_yield_and_advertisement_reset_cadence() {
8613 let (mut engine, remote) = budget_page_engine(1);
8614 engine.set_gasp_remote_factory(Box::new(remote));
8615 engine
8616 .storage
8617 .set_peer_cadence(
8618 "budget.example",
8619 "tm_test",
8620 &crate::sync_load::PeerCadence {
8621 quiet_syncs: 8,
8622 retry_after_secs: 0,
8623 advertisement: String::new(),
8624 },
8625 )
8626 .await
8627 .unwrap();
8628 let result = engine.start_gasp_sync().await.unwrap();
8629 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
8630 assert_eq!(
8631 engine
8632 .storage
8633 .get_peer_cadence("budget.example", "tm_test")
8634 .await
8635 .unwrap()
8636 .quiet_syncs,
8637 0
8638 );
8639 engine
8640 .storage
8641 .set_peer_cadence(
8642 "budget.example",
8643 "tm_test",
8644 &crate::sync_load::PeerCadence {
8645 quiet_syncs: 8,
8646 retry_after_secs: 3_600,
8647 advertisement: "previous-advertisement".into(),
8648 },
8649 )
8650 .await
8651 .unwrap();
8652 let result = engine.start_gasp_sync().await.unwrap();
8653 assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8654 assert!(
8655 engine
8656 .storage
8657 .get_peer_cadence("budget.example", "tm_test")
8658 .await
8659 .unwrap()
8660 .quiet_syncs
8661 <= 1
8662 );
8663 }
8664
8665 #[tokio::test]
8666 async fn load_0112_an_admitted_slap_change_wakes_its_peer() {
8667 let (mut engine, mut remote) = budget_page_engine(0);
8668 remote.node_cost_ms = 0;
8669 engine.set_gasp_remote_factory(Box::new(remote.clone()));
8670 for _ in 0..3 {
8671 engine.start_gasp_sync().await.unwrap();
8672 }
8673 let skipped = engine.start_gasp_sync().await.unwrap();
8674 assert_eq!(
8675 skipped.topics_synced["tm_test"].peers_skipped[0].reason,
8676 "no_yield"
8677 );
8678 let count = remote.requests.borrow().len();
8679 let ad = discovery_output("SLAP", "https://budget.example", "ls_ship");
8680 let tx = beef_limits::transaction_from_beef(&ad.beef, None, &beef_limits::PEER_BEEF_LIMITS)
8681 .unwrap();
8682 engine
8683 .storage
8684 .insert_output(&Output {
8685 txid: tx.id(),
8686 output_index: 0,
8687 output_script: tx.outputs[0].locking_script.to_binary(),
8688 satoshis: 1,
8689 topic: "tm_slap".into(),
8690 spent: false,
8691 outputs_consumed: vec![],
8692 consumed_by: vec![],
8693 beef: None,
8694 block_height: None,
8695 score: Some(1.0),
8696 })
8697 .await
8698 .unwrap();
8699 let result = engine.start_gasp_sync().await.unwrap();
8700 assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8701 assert_eq!(remote.requests.borrow().len(), count + 1);
8702 }
8703
8704 #[tokio::test]
8705 async fn load_0112_filter_selects_only_named_topic_and_peer() {
8706 let calls = std::rc::Rc::default();
8707 let mut engine = Engine::new(
8708 HashMap::new(),
8709 HashMap::new(),
8710 Box::new(MemoryStorage::new()),
8711 None,
8712 EngineConfig {
8713 sync_configuration: ["tm_alpha", "tm_beta"]
8714 .into_iter()
8715 .map(|t| {
8716 (
8717 t.to_string(),
8718 SyncTarget::Peers(vec![
8719 "https://a.example".into(),
8720 "https://b.example".into(),
8721 ]),
8722 )
8723 })
8724 .collect(),
8725 ..Default::default()
8726 },
8727 );
8728 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8729 calls: std::rc::Rc::clone(&calls),
8730 }));
8731 let filter = crate::sync_load::SyncFilter::parse(
8732 r#"{"topics":["tm_beta"],"peers":["https://b.example"]}"#,
8733 )
8734 .unwrap();
8735 let result = engine.start_gasp_sync_filtered(&filter).await.unwrap();
8736 assert_eq!(
8737 *calls.borrow(),
8738 vec![("https://b.example".into(), "tm_beta".into())]
8739 );
8740 assert_eq!(result.topics_synced.len(), 1);
8741 calls.borrow_mut().clear();
8742 engine.start_gasp_sync().await.unwrap();
8743 assert_eq!(calls.borrow().len(), 4);
8744 calls.borrow_mut().clear();
8745 engine
8746 .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8747 topics: Some(vec![]),
8748 peers: None,
8749 })
8750 .await
8751 .unwrap();
8752 assert!(calls.borrow().is_empty());
8753 assert!(crate::sync_load::SyncFilter::parse(r#"{"peers":"bad"}"#).is_err());
8754 assert!(crate::sync_load::SyncFilter::parse("null").is_err());
8755 assert_eq!(
8756 crate::sync_load::SyncFilter::parse(" ").unwrap(),
8757 crate::sync_load::SyncFilter::default()
8758 );
8759 }
8760
8761 #[derive(Clone, Default)]
8762 struct MockShipLookupClient {
8763 records: HashMap<(String, String), Vec<OutputListItem>>,
8764 failures: HashSet<String>,
8765 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
8766 }
8767
8768 struct RecordingDiscoverySyncFactory {
8769 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
8770 }
8771
8772 impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
8773 fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8774 self.calls
8775 .borrow_mut()
8776 .push((peer.to_string(), topic.to_string()));
8777 Box::new(MockSyncRemote { utxos: Vec::new() })
8778 }
8779 }
8780
8781 #[async_trait(?Send)]
8782 impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
8783 async fn lookup(
8784 &self,
8785 host: &str,
8786 question: &LookupQuestion,
8787 ) -> Result<Vec<OutputListItem>, String> {
8788 self.calls
8789 .borrow_mut()
8790 .push((host.to_string(), question.clone()));
8791 if self.failures.contains(host) {
8792 return Err("mock tracker unavailable".to_string());
8793 }
8794 Ok(self
8795 .records
8796 .get(&(host.to_string(), question.service.clone()))
8797 .cloned()
8798 .unwrap_or_default())
8799 }
8800 }
8801
8802 fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
8803 use bsv_rs::script::templates::PushDrop;
8804 let pubkey = bsv_rs::PublicKey::from_hex(
8805 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
8806 )
8807 .unwrap();
8808 let script = PushDrop::new(
8809 pubkey,
8810 vec![
8811 protocol.as_bytes().to_vec(),
8812 vec![0x02; 33],
8813 domain.as_bytes().to_vec(),
8814 scope.as_bytes().to_vec(),
8815 ],
8816 )
8817 .lock();
8818 let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
8819 tx.outputs[0].locking_script = script;
8820 tx.merkle_path = None;
8821 tx.invalidate_caches();
8822 let beef = tx.to_atomic_beef(true).unwrap();
8823 let parsed =
8824 beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
8825 .unwrap();
8826 assert_eq!(
8827 parsed.outputs[0].locking_script,
8828 tx.outputs[0].locking_script
8829 );
8830 OutputListItem {
8831 beef,
8832 output_index: 0,
8833 context: None,
8834 }
8835 }
8836
8837 #[tokio::test]
8838 async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
8839 let (mut engine, _) = make_engine_with_broadcaster(
8840 vec![0],
8841 MockBroadcaster::new(),
8842 vec![
8843 ("https://local.example", "tm_test"),
8844 ("https://shared.example/?local", "tm_test"),
8845 ],
8846 Some("https://self.example"),
8847 )
8848 .await;
8849 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8850 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8851 let mut client = MockShipLookupClient::default();
8852 client.records.insert(
8853 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8854 vec![
8855 discovery_output("SHIP", "https://direct.example", "tm_test"),
8856 discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
8857 discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
8858 discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
8859 discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
8860 OutputListItem {
8861 beef: vec![1, 2, 3],
8862 output_index: 0,
8863 context: None,
8864 },
8865 ],
8866 );
8867 client.records.insert(
8868 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8869 vec![
8870 discovery_output("SLAP", "https://lookup.example", "ls_ship"),
8871 discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
8872 discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
8873 discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
8874 discovery_output("SLAP", "https://other-service.example", "ls_other"),
8875 ],
8876 );
8877 client.records.insert(
8878 ("https://lookup.example".to_string(), "ls_ship".to_string()),
8879 vec![
8880 discovery_output("SHIP", "https://indirect.example", "tm_test"),
8881 discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
8882 ],
8883 );
8884 let calls = client.calls.clone();
8885 engine.set_ship_lookup_client(Box::new(client));
8886 let result = engine.start_gasp_sync().await.unwrap();
8887 assert_eq!(
8888 result.topics_synced["tm_test"].peers,
8889 vec![
8890 "https://bootstrap.example",
8891 "https://direct.example",
8892 "https://indirect.example",
8893 "https://local.example",
8894 "https://shared.example",
8895 ]
8896 );
8897 let calls = calls.borrow();
8898 assert_eq!(calls.len(), 3);
8899 for (_, question) in calls.iter() {
8900 let expected = if question.service == "ls_slap" {
8901 serde_json::json!({"service": "ls_ship"})
8902 } else {
8903 serde_json::json!({"topics": ["tm_test"]})
8904 };
8905 assert_eq!(question.query, expected);
8906 }
8907 }
8908
8909 #[tokio::test]
8910 async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
8911 let (mut engine, _) =
8912 make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
8913 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8914 let mut client = MockShipLookupClient::default();
8915 let outputs = (0..9)
8916 .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
8917 .collect();
8918 client.records.insert(
8919 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8920 outputs,
8921 );
8922 engine.set_ship_lookup_client(Box::new(client));
8923 let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8924 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8925 calls: sync_calls.clone(),
8926 }));
8927 let result = engine.start_gasp_sync().await.unwrap();
8928 let topic = &result.topics_synced["tm_test"];
8929 assert_eq!(topic.sync_type, "ship");
8930 assert_eq!(
8931 topic.peers.len(),
8932 9,
8933 "the lookup-host cap must not truncate sync peers"
8934 );
8935 assert!(topic.errors.is_empty());
8936 assert_eq!(sync_calls.borrow().len(), 9);
8937 for peer in &topic.peers {
8938 assert!(sync_calls
8939 .borrow()
8940 .contains(&(peer.clone(), "tm_test".to_string())));
8941 }
8942 }
8943
8944 #[tokio::test]
8945 async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
8946 let mut engine = make_engine(vec![]);
8947 engine.config.slap_trackers = vec![
8948 "https://dead.example".to_string(),
8949 "https://live.example".to_string(),
8950 ];
8951 let mut client = MockShipLookupClient::default();
8952 client.failures.insert("https://dead.example".to_string());
8953 client.records.insert(
8954 ("https://live.example".to_string(), "ls_ship".to_string()),
8955 vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
8956 );
8957 engine.set_ship_lookup_client(Box::new(client));
8958 let result = engine.start_gasp_sync().await.unwrap();
8959 assert_eq!(
8960 result.topics_synced["tm_test"].peers,
8961 vec!["https://peer.example"]
8962 );
8963 }
8964
8965 #[tokio::test]
8966 async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
8967 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
8968 let mut engine = make_engine(vec![]);
8969 engine
8970 .config
8971 .sync_configuration
8972 .insert("tm_other".to_string(), SyncTarget::Ship);
8973 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8974 let mut client = MockShipLookupClient::default();
8975 let mut providers = Vec::new();
8976 for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
8977 let host = format!("https://lookup{n}.example");
8978 providers.push(discovery_output("SLAP", &host, "ls_ship"));
8979 client.records.insert(
8980 (host, "ls_ship".to_string()),
8981 vec![
8982 discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
8983 discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
8984 ],
8985 );
8986 }
8987 client.records.insert(
8988 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8989 providers,
8990 );
8991 let calls = client.calls.clone();
8992 engine.set_ship_lookup_client(Box::new(client));
8993 let result = engine.start_gasp_sync().await.unwrap();
8994 assert_eq!(
8995 result.topics_synced["tm_test"].peers.len(),
8996 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8997 );
8998 assert_eq!(
8999 result.topics_synced["tm_other"].peers.len(),
9000 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
9001 );
9002 let calls = calls.borrow();
9003 assert_eq!(
9004 calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
9005 1
9006 );
9007 let providers: HashSet<_> = calls
9008 .iter()
9009 .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
9010 .map(|(h, _)| h)
9011 .collect();
9012 assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
9013 assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
9014 }
9015
9016 #[tokio::test]
9017 async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
9018 let mut engine = make_engine(vec![]);
9019 engine.config.hosting_url = Some("https://self.example".to_string());
9020 engine.config.slap_trackers = vec!["https://dead.example".to_string()];
9021 let mut client = MockShipLookupClient::default();
9022 client.failures.insert("https://dead.example".to_string());
9023 engine.set_ship_lookup_client(Box::new(client));
9024 engine.set_ship_bootstrap_peers(
9025 "tm_test",
9026 vec![
9027 "https://bootstrap.example".to_string(),
9028 "https://BOOTSTRAP.example.:443/?x".to_string(),
9029 "http://SELF.example.:8443/path".to_string(),
9030 ],
9031 );
9032 let result = engine.start_gasp_sync().await.unwrap();
9033 assert_eq!(
9034 result.topics_synced["tm_test"].peers,
9035 vec!["https://bootstrap.example"]
9036 );
9037 }
9038
9039 #[tokio::test]
9040 async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
9041 let mut engine = make_engine(vec![]);
9042 for topic in ["tm_ship", "tm_slap"] {
9043 engine.config.sync_configuration.insert(
9044 topic.to_string(),
9045 SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
9046 );
9047 }
9048 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
9049 let mut client = MockShipLookupClient::default();
9050 client.records.insert(
9051 ("https://tracker.example".to_string(), "ls_ship".to_string()),
9052 vec![discovery_output(
9053 "SHIP",
9054 "https://discovered.example",
9055 "tm_test",
9056 )],
9057 );
9058 let lookups = client.calls.clone();
9059 engine.set_ship_lookup_client(Box::new(client));
9060 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
9061 let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
9062 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
9063 calls: calls.clone(),
9064 }));
9065 engine.start_gasp_sync().await.unwrap();
9066 assert_eq!(
9067 calls
9068 .borrow()
9069 .iter()
9070 .map(|(_, topic)| topic.as_str())
9071 .collect::<Vec<_>>(),
9072 vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
9073 );
9074 assert_eq!(lookups.borrow().len(), 2);
9075 }
9076
9077 #[tokio::test]
9078 async fn test_start_gasp_sync_ship_discovers_peers() {
9079 let broadcaster = MockBroadcaster::new();
9080 let (engine, _calls) = make_engine_with_broadcaster(
9081 vec![0],
9082 broadcaster,
9083 vec![
9084 ("https://peer1.example.com", "tm_test"),
9085 ("https://peer2.example.com", "tm_test"),
9086 ],
9087 Some("https://self.example.com"),
9088 )
9089 .await;
9090
9091 let result = engine.start_gasp_sync().await.unwrap();
9092 assert!(
9093 result.topics_synced.contains_key("tm_test"),
9094 "tm_test should be in sync results"
9095 );
9096
9097 let topic_result = &result.topics_synced["tm_test"];
9098 assert_eq!(topic_result.sync_type, "ship");
9099 assert_eq!(
9100 topic_result.peers.len(),
9101 2,
9102 "Should discover both SHIP peers"
9103 );
9104 assert!(topic_result
9105 .peers
9106 .contains(&"https://peer1.example.com".to_string()));
9107 assert!(topic_result
9108 .peers
9109 .contains(&"https://peer2.example.com".to_string()));
9110 }
9111
9112 #[tokio::test]
9113 async fn test_start_gasp_sync_ship_filters_self() {
9114 let broadcaster = MockBroadcaster::new();
9115 let (engine, _calls) = make_engine_with_broadcaster(
9116 vec![0],
9117 broadcaster,
9118 vec![
9119 ("https://self.example.com", "tm_test"),
9120 ("https://peer2.example.com", "tm_test"),
9121 ],
9122 Some("https://self.example.com"),
9123 )
9124 .await;
9125
9126 let result = engine.start_gasp_sync().await.unwrap();
9127 let topic_result = &result.topics_synced["tm_test"];
9128 assert_eq!(
9129 topic_result.peers.len(),
9130 1,
9131 "Self URL should be filtered out from SHIP discovery"
9132 );
9133 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
9134 }
9135
9136 #[tokio::test]
9137 async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
9138 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9139 managers.insert(
9140 "tm_custom".to_string(),
9141 Box::new(MockTopicManager::admitting(vec![])),
9142 );
9143
9144 let mut sync_config: SyncConfiguration = HashMap::new();
9145 sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
9146
9147 let engine = Engine::new(
9148 managers,
9149 HashMap::new(),
9150 Box::new(MemoryStorage::new()),
9151 None,
9152 EngineConfig {
9153 sync_configuration: sync_config,
9154 ..Default::default()
9155 },
9156 );
9157
9158 let result = engine.start_gasp_sync().await.unwrap();
9159 let topic_result = &result.topics_synced["tm_custom"];
9160 assert!(
9161 topic_result.peers.is_empty(),
9162 "Without ls_ship, SHIP discovery should return no peers"
9163 );
9164 }
9165
9166 #[tokio::test]
9167 async fn test_start_gasp_sync_mixed_config() {
9168 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9169 managers.insert(
9170 "tm_peers".to_string(),
9171 Box::new(MockTopicManager::admitting(vec![])),
9172 );
9173 managers.insert(
9174 "tm_disabled".to_string(),
9175 Box::new(MockTopicManager::admitting(vec![])),
9176 );
9177 managers.insert(
9178 "tm_ship_topic".to_string(),
9179 Box::new(MockTopicManager::admitting(vec![])),
9180 );
9181
9182 let mut sync_config: SyncConfiguration = HashMap::new();
9183 sync_config.insert(
9184 "tm_peers".to_string(),
9185 SyncTarget::Peers(vec!["https://peer.com".to_string()]),
9186 );
9187 sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
9188 sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
9189
9190 let engine = Engine::new(
9191 managers,
9192 HashMap::new(),
9193 Box::new(MemoryStorage::new()),
9194 None,
9195 EngineConfig {
9196 sync_configuration: sync_config,
9197 ..Default::default()
9198 },
9199 );
9200
9201 let result = engine.start_gasp_sync().await.unwrap();
9202 assert_eq!(result.topics_synced.len(), 2);
9203 assert!(result.topics_synced.contains_key("tm_peers"));
9204 assert!(result.topics_synced.contains_key("tm_ship_topic"));
9205 assert!(!result.topics_synced.contains_key("tm_disabled"));
9206
9207 assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
9208 assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
9209 assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
9210 }
9211
9212 #[tokio::test]
9213 async fn test_gasp_sync_result_serialization() {
9214 let mut topics_synced = HashMap::new();
9215 topics_synced.insert(
9216 "tm_test".to_string(),
9217 super::TopicSyncResult {
9218 cost: crate::meter::SyncCost::default(),
9219 root_bounds: Vec::new(),
9220 peers_skipped: Vec::new(),
9221 peers_filtered: Vec::new(),
9222 roots_proven: 0,
9223 roots_unproven: 0,
9224 cursor_held_by: Vec::new(),
9225 expired_refused: 0,
9226 peers: vec!["https://peer.com".to_string()],
9227 sync_type: "ship".to_string(),
9228 errors: vec![],
9229 pruned_inputs: 0,
9230 discarded_graphs: 0,
9231 anchor_walk_held_graphs: 0,
9232 finalized_graphs: 0,
9233 deadline_dropped_graphs: 0,
9234 cursor_moves: Vec::new(),
9235 deferred_graphs: 0,
9236 resumed_graphs: 0,
9237 converged_graphs: 0,
9238 dropped_graphs: Vec::new(),
9239 stalled_graphs: 0,
9240 held_back_graphs: 0,
9241 backed_off_graphs: 0,
9242 waiting_graphs: 0,
9243 idle_counted: 0,
9244 peer_results: HashMap::new(),
9245 dead_graphs: 0,
9246 expired_swept: 0,
9247 memo_folded: 0,
9248 unanchorable_graphs: 0,
9249 unanchorable_cleared: 0,
9250 absent_struck: 0,
9251 route_faults: Vec::new(),
9252 strikes_refused: 0,
9253 faulted_roots: Vec::new(),
9254 },
9255 );
9256
9257 let result = super::GASPSyncResult {
9258 cost: crate::meter::SyncCost::default(),
9259 topics_synced,
9260 filter: crate::sync_load::SyncFilter::default(),
9261 topics_filtered: Vec::new(),
9262 };
9263 let json = serde_json::to_string(&result).unwrap();
9264 let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
9265
9266 assert_eq!(back.topics_synced.len(), 1);
9267 assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
9268 assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
9269 }
9270
9271 struct MockSyncRemote {
9275 utxos: Vec<crate::types::GASPOutput>,
9276 }
9277
9278 #[async_trait(?Send)]
9279 impl crate::gasp::GASPRemote for MockSyncRemote {
9280 async fn get_initial_response(
9281 &self,
9282 request: &crate::types::GASPInitialRequest,
9283 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9284 let utxos: Vec<crate::types::GASPOutput> = self
9285 .utxos
9286 .iter()
9287 .filter(|u| u.score as u64 >= request.since)
9288 .cloned()
9289 .collect();
9290 Ok(crate::types::GASPInitialResponse {
9291 utxo_list: utxos,
9292 since: request.since,
9293 })
9294 }
9295 async fn get_initial_reply(
9296 &self,
9297 _: &crate::types::GASPInitialResponse,
9298 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9299 Ok(crate::types::GASPInitialReply {
9300 utxo_list: Vec::new(),
9301 })
9302 }
9303 async fn request_node(
9304 &self,
9305 graph_id: &str,
9306 txid: &str,
9307 output_index: u32,
9308 _: bool,
9309 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9310 Ok(crate::types::GASPNode {
9311 graph_id: graph_id.to_string(),
9312 raw_tx: format!("rawtx_{txid}"),
9313 output_index,
9314 proof: None,
9315 tx_metadata: None,
9316 output_metadata: None,
9317 inputs: None,
9318 })
9319 }
9320 async fn submit_node(
9321 &self,
9322 _: &crate::types::GASPNode,
9323 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9324 Ok(None)
9325 }
9326 }
9327
9328 struct MockGASPRemoteFactory;
9330
9331 #[derive(Clone)]
9332 struct BudgetPageRemote {
9333 outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
9334 nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
9335 elapsed: std::rc::Rc<Cell<u64>>,
9336 requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
9337 clamp: Option<usize>,
9338 node_cost_ms: u64,
9339 slow_once: std::rc::Rc<std::cell::RefCell<Option<(String, u64)>>>,
9341 asked: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
9343 faults_for: std::rc::Rc<std::cell::RefCell<std::collections::HashSet<String>>>,
9345 }
9346
9347 #[async_trait(?Send)]
9348 impl crate::gasp::GASPRemote for BudgetPageRemote {
9349 async fn get_initial_response(
9350 &self,
9351 request: &crate::types::GASPInitialRequest,
9352 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9353 self.requests.borrow_mut().push(request.clone());
9354 let page = self
9355 .outputs
9356 .iter()
9357 .filter(|u| u.score as u64 >= request.since);
9358 let limit = self.clamp.map_or(usize::MAX, |cap| {
9359 cap.min(request.limit.unwrap_or(u64::MAX) as usize)
9360 });
9361 Ok(crate::types::GASPInitialResponse {
9362 utxo_list: page.take(limit).cloned().collect(),
9363 since: request.since,
9364 })
9365 }
9366
9367 async fn get_initial_reply(
9368 &self,
9369 _: &crate::types::GASPInitialResponse,
9370 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9371 unreachable!()
9372 }
9373
9374 async fn request_node(
9375 &self,
9376 graph_id: &str,
9377 txid: &str,
9378 _: u32,
9379 _: bool,
9380 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9381 self.asked.borrow_mut().push(txid.to_string());
9382 self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
9383 let slow = {
9384 let mut slow = self.slow_once.borrow_mut();
9385 match slow.as_ref() {
9386 Some((t, _)) if t == txid => slow.take().map(|(_, ms)| ms),
9387 _ => None,
9388 }
9389 };
9390 if let Some(ms) = slow {
9391 self.elapsed.set(self.elapsed.get() + ms);
9392 }
9393 if self.faults_for.borrow().contains(txid) {
9394 return Err(crate::gasp::GASPError::RemoteError("HTTP 503".into()));
9395 }
9396 if self.node_cost_ms > 0 {
9397 let mut yielded = false;
9398 std::future::poll_fn(|cx| {
9399 if yielded {
9400 std::task::Poll::Ready(())
9401 } else {
9402 yielded = true;
9403 cx.waker().wake_by_ref();
9404 std::task::Poll::Pending
9405 }
9406 })
9407 .await;
9408 }
9409 let mut node = self
9410 .nodes
9411 .get(txid)
9412 .cloned()
9413 .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
9414 node.graph_id = graph_id.to_string();
9415 Ok(node)
9416 }
9417
9418 async fn submit_node(
9419 &self,
9420 _: &crate::types::GASPNode,
9421 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9422 unreachable!()
9423 }
9424 }
9425
9426 impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
9427 fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
9428 Box::new(self.clone())
9429 }
9430 }
9431
9432 fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
9433 let mut engine = make_engine(vec![0]);
9434 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
9435 let mut outputs = Vec::new();
9436 let mut nodes = HashMap::new();
9437 for n in 1..=count {
9438 let tx = child_of(&format!("{n:064x}"), 0);
9439 let txid = tx.id();
9440 tracker.add_root(n, txid.clone());
9441 outputs.push(crate::types::GASPOutput {
9442 txid: txid.clone(),
9443 output_index: 0,
9444 score: f64::from(n * 10),
9445 });
9446 nodes.insert(
9447 txid.clone(),
9448 crate::types::GASPNode {
9449 graph_id: String::new(),
9450 raw_tx: tx.to_hex(),
9451 output_index: 0,
9452 proof: Some(single_leaf_bump_hex(&txid, n)),
9453 tx_metadata: None,
9454 output_metadata: None,
9455 inputs: None,
9456 },
9457 );
9458 }
9459 engine.chain_tracker = Some(Box::new(tracker));
9460 engine.config.sync_configuration.insert(
9461 "tm_test".to_string(),
9462 SyncTarget::Peers(vec!["https://budget.example".to_string()]),
9463 );
9464 let remote = BudgetPageRemote {
9465 outputs: std::rc::Rc::new(outputs),
9466 nodes: std::rc::Rc::new(nodes),
9467 elapsed: std::rc::Rc::new(Cell::new(0)),
9468 requests: std::rc::Rc::default(),
9469 clamp: None,
9470 node_cost_ms: 5000,
9471 slow_once: std::rc::Rc::default(),
9472 asked: std::rc::Rc::default(),
9473 faults_for: std::rc::Rc::default(),
9474 };
9475 (engine, remote)
9476 }
9477
9478 fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
9479 let elapsed = remote.elapsed.clone();
9480 engine.set_peer_sync_budget(
9481 std::rc::Rc::new(move |ms| {
9482 assert_eq!(ms, 30_000);
9483 let elapsed = elapsed.clone();
9484 let until = elapsed.get() + ms;
9485 Box::pin(std::future::poll_fn(move |_| {
9486 if elapsed.get() >= until {
9487 std::task::Poll::Ready(())
9488 } else {
9489 std::task::Poll::Pending
9490 }
9491 }))
9492 }),
9493 30_000,
9494 );
9495 }
9496
9497 #[tokio::test]
9498 async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
9499 let (mut engine, remote) = budget_page_engine(3000);
9502 virtual_peer_budget(&mut engine, &remote);
9503 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9504 for round in 1..=2 {
9505 let result = engine.start_gasp_sync().await.unwrap();
9506 let topic = &result.topics_synced["tm_test"];
9507 assert_eq!(topic.finalized_graphs, 5);
9508 assert_eq!(topic.deadline_dropped_graphs, 1);
9509 assert_eq!(topic.cursor_moves.len(), 1);
9510 let expected = round * 50;
9511 assert_eq!(topic.cursor_moves[0].to, expected);
9512 assert_eq!(
9513 engine
9514 .storage
9515 .get_last_interaction("https://budget.example", "tm_test")
9516 .await
9517 .unwrap(),
9518 expected
9519 );
9520 {
9521 let requests = remote.requests.borrow();
9522 assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
9523 }
9524 for output in remote.outputs.iter().take((round * 5) as usize) {
9525 assert!(engine
9526 .storage
9527 .find_output(&output.txid, 0, Some("tm_test"), None, true)
9528 .await
9529 .unwrap()
9530 .is_some());
9531 }
9532 let unfinished = &remote.outputs[(round * 5) as usize];
9533 assert!(engine
9534 .storage
9535 .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
9536 .await
9537 .unwrap()
9538 .is_none());
9539 }
9540 }
9541
9542 #[tokio::test]
9543 async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
9544 let (mut engine, mut remote) = budget_page_engine(7);
9545 remote.clamp = Some(3);
9546 remote.node_cost_ms = 0;
9547 virtual_peer_budget(&mut engine, &remote);
9548 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9549 let result = engine.start_gasp_sync().await.unwrap();
9550 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
9551 let requests = remote.requests.borrow();
9552 assert_eq!(requests.len(), 4);
9553 assert!(requests.iter().all(|request| request.limit == Some(100)));
9554 assert_eq!(
9555 requests.iter().map(|r| r.since).collect::<Vec<_>>(),
9556 vec![0, 30, 50, 70]
9557 );
9558 }
9559
9560 #[tokio::test]
9561 async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
9562 let (mut engine, mut remote) = budget_page_engine(3000);
9563 std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
9564 virtual_peer_budget(&mut engine, &remote);
9565 engine.set_gasp_remote_factory(Box::new(remote));
9566 let result = engine.start_gasp_sync().await.unwrap();
9567 let topic = &result.topics_synced["tm_test"];
9568 assert_eq!(topic.finalized_graphs, 5);
9569 assert!(topic.cursor_moves.is_empty());
9572 }
9573
9574 #[tokio::test]
9575 async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
9576 let (mut engine, mut remote) = budget_page_engine(3);
9577 std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
9578 remote.node_cost_ms = 10_000;
9579 virtual_peer_budget(&mut engine, &remote);
9580 engine.set_gasp_remote_factory(Box::new(remote));
9581 let result = engine.start_gasp_sync().await.unwrap();
9582 let topic = &result.topics_synced["tm_test"];
9583 assert_eq!(topic.finalized_graphs, 2);
9584 assert_eq!(topic.cursor_moves[0].to, 10);
9585 }
9586
9587 #[tokio::test]
9588 async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
9589 let (mut engine, mut remote) = budget_page_engine(3000);
9590 let failed = remote.outputs[2].txid.clone();
9591 std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
9592 virtual_peer_budget(&mut engine, &remote);
9593 engine.set_gasp_remote_factory(Box::new(remote));
9594 let result = engine.start_gasp_sync().await.unwrap();
9595 let topic = &result.topics_synced["tm_test"];
9596 assert_eq!(topic.finalized_graphs, 4);
9597 assert_eq!(topic.cursor_moves[0].to, 20);
9598 }
9599
9600 struct ProofOnlyRootFetcher {
9601 proofs: HashMap<String, String>,
9602 requested: std::rc::Rc<Cell<u64>>,
9603 }
9604
9605 #[async_trait(?Send)]
9606 impl AncestorFetcher for ProofOnlyRootFetcher {
9607 async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
9608 assert!(
9609 self.requested.get() > 0,
9610 "root proof request must precede ancestry"
9611 );
9612 Err(GASPError::RemoteError("raw source unavailable".to_string()))
9613 }
9614 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
9615 self.requested.set(self.requested.get() + 1);
9616 self.proofs.get(txid).cloned()
9617 }
9618 }
9619
9620 #[tokio::test]
9621 async fn copy_sync_leaves_completion_courier_untouched() {
9622 let (mut engine, mut remote) = budget_page_engine(1);
9623 remote.node_cost_ms = 0;
9624 let txid = remote.outputs[0].txid.clone();
9625 let proof = std::rc::Rc::make_mut(&mut remote.nodes)
9626 .get_mut(&txid)
9627 .unwrap()
9628 .proof
9629 .take()
9630 .unwrap();
9631 let completion_asks = std::rc::Rc::new(Cell::new(0));
9632 let sync_asks = std::rc::Rc::new(Cell::new(0));
9633 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9634 proofs: HashMap::new(),
9635 requested: completion_asks.clone(),
9636 }));
9637 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9638 proofs: HashMap::from([(txid.clone(), proof.clone())]),
9639 requested: sync_asks.clone(),
9640 }));
9641 engine.set_gasp_remote_factory(Box::new(remote));
9642 let result = engine.start_gasp_sync().await.unwrap();
9643 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
9644 assert_eq!(result.topics_synced["tm_test"].roots_proven, 1);
9645 assert_eq!(result.topics_synced["tm_test"].roots_unproven, 0);
9646 let stored = engine
9647 .storage()
9648 .find_output(&txid, 0, Some("tm_test"), None, true)
9649 .await
9650 .unwrap()
9651 .unwrap();
9652 assert!(
9653 crate::beef_limits::own_proof(stored.beef.as_ref().unwrap(), &txid)
9654 .unwrap()
9655 .is_some()
9656 );
9657 assert_eq!(completion_asks.get(), 0);
9658 assert_eq!(sync_asks.get(), 1);
9659 let served = engine
9660 .provide_foreign_gasp_node(&format!("{txid}.0"), &txid, 0)
9661 .await
9662 .unwrap();
9663 assert_eq!(served.proof, Some(proof));
9664 }
9665
9666 #[tokio::test]
9667 async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
9668 for proven in [true, false] {
9669 let (mut engine, mut remote) = budget_page_engine(1);
9670 let txid = remote.outputs[0].txid.clone();
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: if proven {
9679 HashMap::from([(txid.clone(), proof)])
9680 } else {
9681 HashMap::new()
9682 },
9683 requested: requested.clone(),
9684 }));
9685 virtual_peer_budget(&mut engine, &remote);
9686 engine.set_gasp_remote_factory(Box::new(remote));
9687 let result = engine.start_gasp_sync().await.unwrap();
9688 assert!(requested.get() > 0);
9689 assert_eq!(
9690 result.topics_synced["tm_test"].finalized_graphs,
9691 u64::from(proven)
9692 );
9693 assert_eq!(
9694 engine
9695 .storage
9696 .find_output(&txid, 0, Some("tm_test"), None, true)
9697 .await
9698 .unwrap()
9699 .is_some(),
9700 proven
9701 );
9702 if !proven {
9703 assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
9704 }
9705 }
9706 }
9707
9708 #[tokio::test]
9709 async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
9710 let (mut engine, mut remote) = budget_page_engine(1);
9711 let txid = remote.outputs[0].txid.clone();
9712 let parent = bsv_rs::transaction::Transaction::from_hex(&remote.nodes[&txid].raw_tx)
9716 .unwrap()
9717 .inputs[0]
9718 .source_txid
9719 .clone()
9720 .unwrap();
9721 remote.faults_for.borrow_mut().insert(parent);
9722 let proof = remote.nodes[&txid].proof.clone().unwrap();
9723 std::rc::Rc::make_mut(&mut remote.nodes)
9724 .get_mut(&txid)
9725 .unwrap()
9726 .proof = None;
9727 let requested = std::rc::Rc::new(Cell::new(0));
9728 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9729 proofs: HashMap::new(),
9730 requested: requested.clone(),
9731 }));
9732 virtual_peer_budget(&mut engine, &remote);
9733 engine.set_graph_budget(
9734 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9735 5,
9736 5000,
9737 );
9738 engine.set_gasp_remote_factory(Box::new(remote));
9739 let held = engine.start_gasp_sync().await.unwrap();
9740 assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
9741 assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
9742 assert_eq!(held.topics_synced["tm_test"].cursor_moves[0].to, 10);
9743 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9744 proofs: HashMap::from([(txid.clone(), proof)]),
9745 requested,
9746 }));
9747 engine
9748 .storage()
9749 .update_last_interaction("https://budget.example", "tm_test", 20)
9750 .await
9751 .unwrap();
9752 let landed = engine.start_gasp_sync().await.unwrap();
9753 assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
9754 assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
9755 assert!(landed.topics_synced["tm_test"].cursor_moves.is_empty());
9756 assert!(engine
9757 .storage
9758 .find_output(&txid, 0, Some("tm_test"), None, true)
9759 .await
9760 .unwrap()
9761 .is_some());
9762 }
9763
9764 #[tokio::test]
9765 async fn proofs_deadline_stalls_become_durable_after_three_runs() {
9766 let (mut engine, remote) = budget_page_engine(1);
9767 let elapsed = remote.elapsed.clone();
9768 let root = format!("{}.0", remote.outputs[0].txid);
9769 engine.set_graph_budget(
9770 std::rc::Rc::new(move |ms| {
9771 let until = elapsed.get() + ms;
9772 let elapsed = elapsed.clone();
9773 Box::pin(std::future::poll_fn(move |_| {
9774 if elapsed.get() >= until {
9775 std::task::Poll::Ready(())
9776 } else {
9777 std::task::Poll::Pending
9778 }
9779 }))
9780 }),
9781 10,
9782 1_000,
9783 );
9784 engine.set_gasp_remote_factory(Box::new(remote));
9785 for round in 1..=crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9786 let result = engine.start_gasp_sync().await.unwrap();
9787 let topic = &result.topics_synced["tm_test"];
9788 if round < crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9789 assert!(topic.cursor_moves.is_empty());
9790 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
9791 } else {
9792 assert_eq!(topic.cursor_moves[0].to, 10);
9793 assert_eq!(topic.deferred_graphs, 1);
9794 }
9795 }
9796 let held = engine
9797 .storage()
9798 .get_deferred_graph("https://budget.example", "tm_test", &root)
9799 .await
9800 .unwrap()
9801 .unwrap();
9802 assert!(held.cursor_passable);
9803 assert!(held.nodes.is_empty());
9804 }
9805
9806 #[tokio::test]
9812 async fn cursor_018_the_graph_in_flight_at_the_peer_deadline_is_held_and_passed() {
9813 let (mut engine, remote) = budget_page_engine(6);
9814 let slow = remote.outputs[2].txid.clone();
9815 let root = format!("{slow}.0");
9816 *remote.slow_once.borrow_mut() = Some((slow.clone(), 60_000));
9817 virtual_peer_budget(&mut engine, &remote);
9818 engine.set_graph_budget(
9819 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9820 100,
9821 60_000,
9822 );
9823 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9824 let result = engine.start_gasp_sync().await.unwrap();
9825 let topic = &result.topics_synced["tm_test"];
9826 assert_eq!(topic.finalized_graphs, 2);
9827 assert_eq!(topic.deadline_dropped_graphs, 1);
9828 assert_eq!(topic.deferred_graphs, 1);
9829 assert_eq!(topic.cursor_moves.len(), 1);
9830 assert_eq!(topic.cursor_moves[0].to, 30, "past the held graph");
9831 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
9832 let held = engine
9833 .storage()
9834 .get_deferred_graph("https://budget.example", "tm_test", &root)
9835 .await
9836 .unwrap()
9837 .unwrap();
9838 assert!(held.cursor_passable);
9839 assert_eq!(held.reason, "peer_deadline");
9840 assert!(held.nodes.is_empty());
9841 assert_eq!(held.score, 30);
9842
9843 let result = engine.start_gasp_sync().await.unwrap();
9844 let topic = &result.topics_synced["tm_test"];
9845 assert_eq!(topic.resumed_graphs, 1);
9846 assert_eq!(topic.converged_graphs, 1);
9847 assert_eq!(topic.finalized_graphs, 4, "the held graph and 40, 50, 60");
9848 assert_eq!(topic.cursor_moves[0].to, 60);
9849 assert!(engine
9850 .storage()
9851 .get_deferred_graph("https://budget.example", "tm_test", &root)
9852 .await
9853 .unwrap()
9854 .is_none());
9855 for output in remote.outputs.iter() {
9856 assert!(engine
9857 .storage
9858 .find_output(&output.txid, 0, Some("tm_test"), None, true)
9859 .await
9860 .unwrap()
9861 .is_some());
9862 }
9863 }
9864
9865 struct ExpiredRoots(MockTopicManager);
9867
9868 #[async_trait(?Send)]
9869 impl TopicManagerTrait for ExpiredRoots {
9870 async fn identify_admissible_outputs(
9871 &self,
9872 tx: &Transaction,
9873 previous_coins: &[u8],
9874 off_chain_values: Option<&[u8]>,
9875 mode: SubmitMode,
9876 context: &TopicAdmittanceContext,
9877 ) -> Result<AdmittanceInstructions, TopicManagerError> {
9878 self.0
9879 .identify_admissible_outputs(tx, previous_coins, off_chain_values, mode, context)
9880 .await
9881 }
9882 async fn get_documentation(&self) -> String {
9883 self.0.get_documentation().await
9884 }
9885 async fn get_metadata(&self) -> ServiceMetadata {
9886 self.0.get_metadata().await
9887 }
9888 fn sync_expiry(&self, _: &bsv_rs::script::LockingScript) -> Option<u64> {
9889 Some(1)
9890 }
9891 }
9892
9893 fn dead_page_engine(count: u32) -> (Engine, BudgetPageRemote, std::rc::Rc<MemoryStorage>) {
9896 let (mut engine, mut remote) = budget_page_engine(count);
9897 let gap = remote.outputs[0].txid.clone();
9898 std::rc::Rc::make_mut(&mut remote.nodes).remove(&gap);
9899 engine.managers.insert(
9900 "tm_test".to_string(),
9901 Box::new(ExpiredRoots(MockTopicManager::admitting(vec![0]))),
9902 );
9903 let store = std::rc::Rc::new(MemoryStorage::new());
9904 engine.storage = Box::new(store.clone());
9905 engine.set_graph_budget(
9906 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9907 100,
9908 60_000,
9909 );
9910 (engine, remote, store)
9911 }
9912
9913 #[tokio::test]
9917 async fn cursor_018_a_sync_that_only_refuses_the_dead_is_a_yield() {
9918 let (mut engine, mut remote, store) = dead_page_engine(3);
9919 remote.node_cost_ms = 0;
9920 virtual_peer_budget(&mut engine, &remote);
9921 engine.set_gasp_remote_factory(Box::new(remote));
9922 let result = engine.start_gasp_sync().await.unwrap();
9923 let topic = &result.topics_synced["tm_test"];
9924 assert_eq!(topic.expired_refused, 2);
9925 assert_eq!(topic.finalized_graphs, 0);
9926 assert!(topic.cursor_moves.is_empty());
9927 let host = crate::gasp::peer_origin("https://budget.example");
9928 assert_eq!(store.peer_yieldless_syncs(&host, "tm_test"), 0);
9929 let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9930 assert_eq!(health.consecutive_failures, 0);
9931 }
9932
9933 #[tokio::test]
9939 async fn cursor_018_deadline_drops_in_a_row_do_not_quarantine_a_serving_peer() {
9940 let (mut engine, remote, store) = dead_page_engine(200);
9941 virtual_peer_budget(&mut engine, &remote);
9942 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9943 let host = crate::gasp::peer_origin("https://budget.example");
9944 let runs = crate::gasp::PEER_QUARANTINE_THRESHOLD + 1;
9945 let gap = format!("{}.0", remote.outputs[0].txid);
9946 for run in 1..=runs {
9947 if let Some(mut record) = store
9951 .get_deferred_graph("https://budget.example", "tm_test", &gap)
9952 .await
9953 .unwrap()
9954 .filter(|r| r.reason.starts_with("retry:fault:"))
9955 {
9956 record.retry_at = 0;
9957 store.put_deferred_graph(&record).await.unwrap();
9958 }
9959 let asked = remote.asked.borrow().len();
9960 let result = engine.start_gasp_sync().await.unwrap();
9961 let topic = &result.topics_synced["tm_test"];
9962 assert!(remote.asked.borrow().len() > asked, "run {run}: attempted");
9963 assert_eq!(topic.deadline_dropped_graphs, 1, "run {run}");
9964 assert!(topic.expired_refused > 0, "run {run}: {topic:?}");
9965 if run < u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9969 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
9970 } else if run == u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9971 assert_eq!(topic.unanchorable_graphs, 1, "run {run}: {topic:?}");
9972 assert!(!topic.cursor_moves.is_empty(), "run {run}: passed");
9973 }
9974 let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9975 assert_eq!(health.consecutive_failures, 0, "run {run}");
9976 }
9977 let asked = remote.asked.borrow();
9981 let mut dead: Vec<&String> = asked
9982 .iter()
9983 .filter(|t| **t != remote.outputs[0].txid)
9984 .collect();
9985 let fetched = dead.len();
9986 dead.sort();
9987 dead.dedup();
9988 assert_eq!(
9989 fetched - dead.len(),
9990 (runs - 1) as usize,
9991 "one per resumed cut"
9992 );
9993 assert_eq!(store.expired_outpoints("tm_test").len(), dead.len() - 1);
9994 }
9995
9996 struct GatedCourier {
9999 txid: String,
10000 raw_tx: String,
10001 proof: String,
10002 serve: std::rc::Rc<Cell<bool>>,
10003 asked: std::rc::Rc<Cell<u64>>,
10004 }
10005
10006 #[async_trait(?Send)]
10007 impl AncestorFetcher for GatedCourier {
10008 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10009 self.asked.set(self.asked.get() + 1);
10010 if self.serve.get() && txid == self.txid {
10011 Ok(FetchedAncestor {
10012 raw_tx: self.raw_tx.clone(),
10013 proof: Some(self.proof.clone()),
10014 })
10015 } else {
10016 Err(GASPError::RemoteError(format!(
10017 "no rung served the raw of {txid}"
10018 )))
10019 }
10020 }
10021 async fn verified_proof_for(&self, _: &str) -> Option<String> {
10022 None
10023 }
10024 }
10025
10026 #[tokio::test]
10030 async fn load_0112_definitive_absence_strikes_a_resumed_obligation() {
10031 let mut fixture = obligation_fixture(1, 1, 0).await;
10032 fixture
10033 .engine
10034 .set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10035 calls: std::rc::Rc::new(Cell::new(0)),
10036 bounded: false,
10037 }));
10038 let host = "https://budget.example";
10042 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10043 for run in 1..=runs {
10044 let result = fixture.engine.start_gasp_sync().await.unwrap();
10045 let topic = &result.topics_synced["tm_test"];
10046 let record = fixture
10047 .store
10048 .get_deferred_graph(host, "tm_test", &fixture.obligations[0])
10049 .await
10050 .unwrap()
10051 .unwrap();
10052 assert_eq!(record.faults, run);
10053 assert!(record.cursor_passable);
10054 if run == 1 {
10055 assert_eq!(topic.finalized_graphs, 1);
10056 }
10057 if run < runs {
10058 assert_eq!(topic.unanchorable_graphs, 0);
10059 assert_eq!(record.reason, "retry:fault:not_found");
10060 let mut record = record;
10061 record.retry_at = 0;
10062 fixture.store.put_deferred_graph(&record).await.unwrap();
10063 } else {
10064 assert_eq!(topic.unanchorable_graphs, 1);
10065 assert_eq!(record.reason, "unanchorable:not_found");
10066 }
10067 }
10068 }
10069
10070 #[tokio::test]
10071 async fn load_0112_root_time_is_clamped_and_named() {
10072 let (mut engine, mut remote) = budget_page_engine(1);
10073 remote.node_cost_ms = 16_000;
10074 let elapsed = remote.elapsed.clone();
10075 let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10076 let clock = elapsed.clone();
10077 let due = clock.get() + ms;
10078 Box::pin(std::future::poll_fn(move |_| {
10079 if clock.get() >= due {
10080 std::task::Poll::Ready(())
10081 } else {
10082 std::task::Poll::Pending
10083 }
10084 }))
10085 });
10086 engine.set_peer_sync_budget(sleep.clone(), 30_000);
10087 engine.set_graph_budget(sleep, 100, 60_000);
10088 engine.set_gasp_remote_factory(Box::new(remote));
10089 let result = engine.start_gasp_sync().await.unwrap();
10090 let topic = &result.topics_synced["tm_test"];
10091 assert_eq!(topic.finalized_graphs, 0);
10092 assert_eq!(topic.root_bounds[0].reason, "time");
10093 assert!(!topic.cursor_held_by.is_empty() || topic.deferred_graphs > 0);
10094 }
10095
10096 #[tokio::test]
10097 async fn load_0112_storage_counts_are_attempts_at_the_boundary() {
10098 let engine = make_engine(vec![0]);
10099 let meter = crate::meter::LoadMeter::default();
10100 meter
10101 .scope(async {
10102 assert_eq!(
10103 engine
10104 .storage
10105 .get_last_interaction("peer", "topic")
10106 .await
10107 .unwrap(),
10108 0
10109 );
10110 engine
10111 .storage
10112 .update_last_interaction("peer", "topic", 12)
10113 .await
10114 .unwrap();
10115 assert_eq!(
10116 engine
10117 .storage
10118 .get_last_interaction("peer", "topic")
10119 .await
10120 .unwrap(),
10121 12
10122 );
10123 })
10124 .await;
10125 assert_eq!(
10126 (
10127 meter.snapshot().storage_reads,
10128 meter.snapshot().storage_writes
10129 ),
10130 (2, 1)
10131 );
10132 engine
10133 .storage
10134 .get_last_interaction("peer", "topic")
10135 .await
10136 .unwrap();
10137 assert_eq!(meter.snapshot().storage_reads, 2);
10138 }
10139
10140 struct LoadCourier {
10141 calls: std::rc::Rc<Cell<u64>>,
10142 bounded: bool,
10143 }
10144 #[async_trait(?Send)]
10145 impl crate::gasp::AncestorFetcher for LoadCourier {
10146 async fn fetch_ancestor(
10147 &self,
10148 txid: &str,
10149 ) -> Result<crate::gasp::FetchedAncestor, crate::gasp::GASPError> {
10150 self.calls.set(self.calls.get() + 1);
10151 if self.bounded {
10152 for _ in 0..13 {
10153 crate::meter::courier_request()
10154 .map_err(|e| crate::gasp::GASPError::Other(e.into()))?;
10155 }
10156 Err(crate::gasp::GASPError::RemoteError(
10157 "still missing a proof".into(),
10158 ))
10159 } else {
10160 for _ in 0..3 {
10161 crate::meter::courier_request().unwrap();
10162 }
10163 Err(crate::gasp::GASPError::DefinitiveNotFound(txid.into()))
10164 }
10165 }
10166 async fn verified_proof_for(&self, _: &str) -> Option<String> {
10167 None
10168 }
10169 }
10170
10171 #[tokio::test]
10172 async fn load_0112_definitive_absence_is_shared_across_peers_and_topics() {
10173 let UnanchorableFixture {
10174 mut engine,
10175 remote,
10176 root,
10177 ..
10178 } = unanchorable_fixture();
10179 let calls = std::rc::Rc::new(Cell::new(0));
10180 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10181 calls: calls.clone(),
10182 bounded: false,
10183 }));
10184 engine.managers.insert(
10185 "tm_other".into(),
10186 Box::new(MockTopicManager::admitting(vec![0])),
10187 );
10188 for topic in ["tm_test", "tm_other"] {
10189 engine.config.sync_configuration.insert(
10190 topic.into(),
10191 SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
10192 );
10193 }
10194 let result = engine.start_gasp_sync().await.unwrap();
10195 assert_eq!(calls.get(), 1, "one ladder per missing ancestor per run");
10196 assert_eq!(result.cost.courier_not_found_memo_hits, 3);
10197 assert_eq!(result.cost.courier_rungs, 3);
10198 for (topic, res) in &result.topics_synced {
10199 assert_eq!(res.unanchorable_graphs, 0, "{res:?}");
10202 assert_eq!(
10203 res.cost.courier_rungs + res.cost.peer_requests,
10204 res.cost.calls_out
10205 );
10206 for host in ["https://a.example", "https://b.example"] {
10207 let mut record = engine
10208 .storage
10209 .get_deferred_graph(host, topic, &root)
10210 .await
10211 .unwrap()
10212 .unwrap();
10213 assert_eq!(record.reason, "retry:fault:not_found");
10214 assert_eq!(record.faults, 1);
10215 assert!(!record.cursor_passable);
10216 record.retry_at = 0;
10217 engine.storage.put_deferred_graph(&record).await.unwrap();
10218 }
10219 }
10220 assert!(!remote.asked.borrow().is_empty());
10221 engine.start_gasp_sync().await.unwrap();
10222 assert_eq!(calls.get(), 2, "another run can retry absence");
10223 }
10224
10225 #[tokio::test]
10226 async fn load_0112_courier_bound_defers_the_root_and_names_it() {
10227 let UnanchorableFixture {
10228 mut engine, root, ..
10229 } = unanchorable_fixture();
10230 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10231 calls: std::rc::Rc::new(Cell::new(0)),
10232 bounded: true,
10233 }));
10234 let result = engine.start_gasp_sync().await.unwrap();
10235 let record = engine
10236 .storage
10237 .get_deferred_graph("https://budget.example", "tm_test", &root)
10238 .await
10239 .unwrap()
10240 .expect("bounded root must be retained");
10241 assert_eq!(record.reason, "courier_rungs");
10242 assert!(!record.nodes.is_empty());
10243 assert_eq!(result.cost.courier_rungs, 12);
10244 assert_eq!(
10245 result.topics_synced["tm_test"].root_bounds[0].reason,
10246 "courier_rungs"
10247 );
10248 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 2);
10249 }
10250
10251 #[tokio::test]
10252 async fn load_0112_refused_save_does_not_restart_a_bounded_root() {
10253 let UnanchorableFixture {
10254 mut engine,
10255 store,
10256 root,
10257 ..
10258 } = unanchorable_fixture();
10259 store.set_deferred_graph_ceiling(Some(0));
10260 let calls = std::rc::Rc::new(Cell::new(0));
10261 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10262 calls: calls.clone(),
10263 bounded: true,
10264 }));
10265 let result = engine.start_gasp_sync().await.unwrap();
10266 let topic = &result.topics_synced["tm_test"];
10267 assert_eq!(calls.get(), 1, "no retry after a hard root cut");
10268 assert_eq!(topic.root_bounds.len(), 1);
10269 assert_eq!(result.cost.courier_rungs, 12);
10270 assert!(topic.cursor_held_by.contains(&root));
10271 assert_eq!(
10272 topic.strikes_refused, 0,
10273 "a budget cut is not a fault strike"
10274 );
10275 assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10276 assert_eq!(topic.finalized_graphs, 2);
10277 }
10278
10279 #[tokio::test]
10280 async fn load_0112_refused_save_does_not_extend_root_time() {
10281 let (mut engine, mut remote) = budget_page_engine(1);
10282 remote.node_cost_ms = 16_000;
10283 let elapsed = remote.elapsed.clone();
10284 let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10285 let clock = elapsed.clone();
10286 let due = clock.get() + ms;
10287 Box::pin(std::future::poll_fn(move |_| {
10288 if clock.get() >= due {
10289 std::task::Poll::Ready(())
10290 } else {
10291 std::task::Poll::Pending
10292 }
10293 }))
10294 });
10295 let store = std::rc::Rc::new(MemoryStorage::new());
10296 store.set_deferred_graph_ceiling(Some(0));
10297 engine.storage = Box::new(store);
10298 engine.set_peer_sync_budget(sleep.clone(), 30_000);
10299 engine.set_graph_budget(sleep, 100, 60_000);
10300 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10301 let result = engine.start_gasp_sync().await.unwrap();
10302 let topic = &result.topics_synced["tm_test"];
10303 assert_eq!(
10304 remote.asked.borrow().len(),
10305 1,
10306 "no request after the time cut"
10307 );
10308 assert_eq!(topic.root_bounds.len(), 1);
10309 assert_eq!(topic.root_bounds[0].reason, "time");
10310 assert_eq!(topic.finalized_graphs, 0);
10311 assert!(!topic.cursor_held_by.is_empty());
10312 }
10313
10314 #[tokio::test]
10315 async fn load_0112_call_limit_fallback_keeps_the_root_deadline() {
10316 let UnanchorableFixture {
10317 mut engine,
10318 mut remote,
10319 store,
10320 root,
10321 serve,
10322 ..
10323 } = unanchorable_fixture();
10324 store.set_deferred_graph_ceiling(Some(0));
10325 serve.set(true);
10326 remote.node_cost_ms = 8_000;
10327 struct DelayedCourier {
10328 inner: std::rc::Rc<dyn AncestorFetcher>,
10329 clock: std::rc::Rc<Cell<u64>>,
10330 }
10331 #[async_trait(?Send)]
10332 impl AncestorFetcher for DelayedCourier {
10333 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10334 self.clock.set(self.clock.get() + 8_000);
10335 tokio::task::yield_now().await;
10336 self.inner.fetch_ancestor(txid).await
10337 }
10338 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
10339 self.inner.verified_proof_for(txid).await
10340 }
10341 }
10342 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(DelayedCourier {
10343 inner: engine.gasp_ancestor_fetcher.as_ref().unwrap().clone(),
10344 clock: remote.elapsed.clone(),
10345 }));
10346 let elapsed = remote.elapsed.clone();
10347 let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10348 let clock = elapsed.clone();
10349 let due = clock.get() + ms;
10350 Box::pin(std::future::poll_fn(move |_| {
10351 if clock.get() >= due {
10352 std::task::Poll::Ready(())
10353 } else {
10354 std::task::Poll::Pending
10355 }
10356 }))
10357 });
10358 engine.set_peer_sync_budget(sleep.clone(), 30_000);
10359 engine.set_graph_budget(sleep, 1, 60_000);
10360 engine.set_gasp_remote_factory(Box::new(remote));
10361 let result = engine.start_gasp_sync().await.unwrap();
10362 let topic = &result.topics_synced["tm_test"];
10363 assert!(
10364 topic
10365 .root_bounds
10366 .iter()
10367 .any(|b| b.outpoint == root && b.reason == "time"),
10368 "{topic:?}"
10369 );
10370 assert!(topic.cursor_held_by.contains(&root));
10371 assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10372 }
10373
10374 #[tokio::test]
10375 async fn load_0112_cost_matches_the_remote_call_log() {
10376 let (mut engine, remote) = budget_page_engine(2);
10377 remote.elapsed.set(0);
10378 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10379 let result = engine.start_gasp_sync().await.unwrap();
10380 let actual_calls = (remote.requests.borrow().len() + remote.asked.borrow().len()) as u64;
10381 assert_eq!(result.cost.peer_requests, actual_calls);
10382 assert_eq!(result.cost.calls_out, actual_calls);
10383 assert!(result.cost.storage_reads > 0);
10384 assert!(result.cost.storage_writes > 0);
10385 assert_eq!(
10386 result.cost.storage_reads,
10387 result.topics_synced["tm_test"].cost.storage_reads
10388 );
10389 assert_eq!(
10390 result.cost.storage_writes,
10391 result.topics_synced["tm_test"].cost.storage_writes
10392 );
10393 }
10394
10395 struct UnanchorableFixture {
10396 engine: Engine,
10397 remote: BudgetPageRemote,
10398 store: std::rc::Rc<MemoryStorage>,
10399 root: String,
10400 serve: std::rc::Rc<Cell<bool>>,
10401 asked: std::rc::Rc<Cell<u64>>,
10402 }
10403
10404 fn unanchorable_fixture() -> UnanchorableFixture {
10405 let (mut engine, mut remote) = budget_page_engine(3);
10406 remote.node_cost_ms = 0;
10407 let parent = child_of(&format!("{:064x}", 0xab), 0);
10408 let child = child_of(&parent.id(), 0);
10409 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
10410 for n in 1..=3u32 {
10411 tracker.add_root(n, child_of(&format!("{n:064x}"), 0).id());
10412 }
10413 tracker.add_root(900, parent.id());
10414 engine.chain_tracker = Some(Box::new(tracker));
10415 let replaced = std::mem::replace(
10416 &mut std::rc::Rc::make_mut(&mut remote.outputs)[0].txid,
10417 child.id(),
10418 );
10419 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10420 nodes.remove(&replaced);
10421 nodes.insert(
10422 child.id(),
10423 crate::types::GASPNode {
10424 graph_id: String::new(),
10425 raw_tx: child.to_hex(),
10426 output_index: 0,
10427 proof: None,
10428 tx_metadata: None,
10429 output_metadata: None,
10430 inputs: None,
10431 },
10432 );
10433 remote.faults_for.borrow_mut().insert(parent.id());
10438 let serve = std::rc::Rc::new(Cell::new(false));
10439 let asked = std::rc::Rc::new(Cell::new(0));
10440 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(GatedCourier {
10441 txid: parent.id(),
10442 raw_tx: parent.to_hex(),
10443 proof: single_leaf_bump_hex(&parent.id(), 900),
10444 serve: serve.clone(),
10445 asked: asked.clone(),
10446 }));
10447 let store = std::rc::Rc::new(MemoryStorage::new());
10448 engine.storage = Box::new(store.clone());
10449 virtual_peer_budget(&mut engine, &remote);
10450 engine.set_graph_budget(
10451 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10452 100,
10453 60_000,
10454 );
10455 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10456 UnanchorableFixture {
10457 engine,
10458 remote,
10459 store,
10460 root: format!("{}.0", child.id()),
10461 serve,
10462 asked,
10463 }
10464 }
10465
10466 #[tokio::test]
10475 async fn unanchorable_019_a_root_faulted_three_runs_is_recorded_passed_and_admitted_later() {
10476 let UnanchorableFixture {
10477 engine,
10478 remote,
10479 store,
10480 root,
10481 serve,
10482 asked,
10483 } = unanchorable_fixture();
10484 let host = "https://budget.example";
10485 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10486 for run in 1..=runs {
10487 let result = engine.start_gasp_sync().await.unwrap();
10488 let topic = &result.topics_synced["tm_test"];
10489 assert_eq!(topic.faulted_roots.len(), 1, "run {run}: {topic:?}");
10490 let named = &topic.faulted_roots[0];
10491 assert_eq!(
10492 (named.peer.as_str(), named.outpoint.as_str()),
10493 (host, root.as_str())
10494 );
10495 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10496 assert_eq!(named.faults, run, "run {run}");
10497 assert!(named.error.contains("no rung served"), "{named:?}");
10498 let record = engine
10499 .storage()
10500 .get_deferred_graph(host, "tm_test", &root)
10501 .await
10502 .unwrap()
10503 .unwrap();
10504 assert_eq!(record.faults, run);
10505 assert!(record.nodes.is_empty(), "the root alone, asked again");
10506 if run < runs {
10507 assert!(!named.unanchorable);
10508 assert_eq!(topic.unanchorable_graphs, 0);
10509 assert_eq!(record.reason, "retry:fault:courier");
10510 assert!(!record.cursor_passable);
10511 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10512 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10513 } else {
10514 assert!(named.unanchorable);
10515 assert_eq!(topic.unanchorable_graphs, 1);
10516 assert_eq!(record.reason, "unanchorable:courier");
10517 assert!(record.cursor_passable);
10518 assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
10519 assert_eq!(topic.cursor_moves.len(), 1);
10520 assert_eq!(
10521 topic.cursor_moves[0].to, 30,
10522 "the page's last finished score"
10523 );
10524 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10525 }
10526 if run == 1 {
10527 assert_eq!(topic.finalized_graphs, 2, "20 and 30");
10528 }
10529 }
10530
10531 let (courier_asks, node_asks) = (asked.get(), remote.asked.borrow().len());
10534 let streak = store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test");
10535 let result = engine.start_gasp_sync().await.unwrap();
10536 let topic = &result.topics_synced["tm_test"];
10537 assert!(topic.faulted_roots.is_empty(), "{topic:?}");
10538 assert!(topic.cursor_held_by.is_empty());
10539 assert_eq!(topic.unanchorable_graphs, 0);
10540 assert_eq!(asked.get(), courier_asks);
10541 assert_eq!(remote.asked.borrow().len(), node_asks);
10542 assert_eq!(
10543 store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test"),
10544 streak
10545 );
10546
10547 let mut record = engine
10550 .storage()
10551 .get_deferred_graph(host, "tm_test", &root)
10552 .await
10553 .unwrap()
10554 .unwrap();
10555 record.retry_at = 1;
10556 engine.storage().put_deferred_graph(&record).await.unwrap();
10557 serve.set(true);
10558 let result = engine.start_gasp_sync().await.unwrap();
10559 let topic = &result.topics_synced["tm_test"];
10560 assert_eq!(topic.finalized_graphs, 1, "{topic:?}");
10561 assert_eq!(topic.unanchorable_cleared, 1);
10562 assert!(topic.faulted_roots.is_empty());
10563 assert!(engine
10564 .storage()
10565 .get_deferred_graph(host, "tm_test", &root)
10566 .await
10567 .unwrap()
10568 .is_none());
10569 let (txid, _) = crate::gasp::parse_outpoint(&root).unwrap();
10570 assert!(engine
10571 .storage
10572 .find_output(&txid, 0, Some("tm_test"), None, true)
10573 .await
10574 .unwrap()
10575 .is_some());
10576 }
10577
10578 #[tokio::test]
10583 async fn unanchorable_019_only_the_lowest_faulted_root_is_recorded_and_all_are_named() {
10584 let (mut engine, mut remote) = budget_page_engine(4);
10585 remote.node_cost_ms = 0;
10586 for idx in [1usize, 3] {
10594 let parent = child_of(&format!("{:064x}", 0xdead_0000 + idx), 0);
10595 let child = child_of(&parent.id(), 0);
10596 let old = std::mem::replace(
10597 &mut std::rc::Rc::make_mut(&mut remote.outputs)[idx].txid,
10598 child.id(),
10599 );
10600 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10601 nodes.remove(&old);
10602 nodes.insert(
10603 child.id(),
10604 crate::types::GASPNode {
10605 graph_id: String::new(),
10606 raw_tx: child.to_hex(),
10607 output_index: 0,
10608 proof: None,
10609 tx_metadata: None,
10610 output_metadata: None,
10611 inputs: None,
10612 },
10613 );
10614 remote.faults_for.borrow_mut().insert(parent.id());
10615 }
10616 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
10617 asked: std::rc::Rc::default(),
10618 transient: std::rc::Rc::default(),
10619 }));
10620 let store = std::rc::Rc::new(MemoryStorage::new());
10621 engine.storage = Box::new(store.clone());
10622 virtual_peer_budget(&mut engine, &remote);
10623 engine.set_graph_budget(
10624 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10625 100,
10626 60_000,
10627 );
10628 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10629 let result = engine.start_gasp_sync().await.unwrap();
10630 let topic = &result.topics_synced["tm_test"];
10631 let named: Vec<(&str, &str, u32)> = topic
10632 .faulted_roots
10633 .iter()
10634 .map(|f| (f.outpoint.as_str(), f.class.as_str(), f.faults))
10635 .collect();
10636 let low = format!("{}.0", remote.outputs[1].txid);
10637 let high = format!("{}.0", remote.outputs[3].txid);
10638 assert_eq!(
10639 named,
10640 vec![
10641 (low.as_str(), crate::gasp::fault_class::COURIER, 1),
10642 (high.as_str(), crate::gasp::fault_class::COURIER, 0),
10643 ]
10644 );
10645 assert_eq!(topic.cursor_moves[0].to, 10, "below the lower gap");
10646 let host = "https://budget.example";
10647 assert!(store
10648 .get_deferred_graph(host, "tm_test", &high)
10649 .await
10650 .unwrap()
10651 .is_none());
10652 assert_eq!(
10653 store
10654 .get_deferred_graph(host, "tm_test", &low)
10655 .await
10656 .unwrap()
10657 .unwrap()
10658 .reason,
10659 "retry:fault:courier"
10660 );
10661 }
10662
10663 fn bare_record(n: u32, score: u64, reason: &str, passable: bool) -> crate::gasp::DeferredGraph {
10666 let outpoint = format!("{:064x}.0", 0xdead_0000 + n);
10667 crate::gasp::DeferredGraph {
10668 peer: "https://budget.example".into(),
10669 topic: "tm_test".into(),
10670 outpoint: outpoint.clone(),
10671 score,
10672 nodes: Vec::new(),
10673 pending: vec![crate::gasp::PendingInput {
10674 outpoint: outpoint.clone(),
10675 graph_id: outpoint,
10676 metadata: true,
10677 spent_by: None,
10678 parent_proven: false,
10679 }],
10680 calls: 0,
10681 passes: 0,
10682 reason: reason.into(),
10683 configured: true,
10684 idle_faults: 0,
10685 cursor_passable: passable,
10686 faults: 0,
10687 retry_at: 0,
10688 idle_resumes: 0,
10689 unanchorable_retries: 0,
10690 expiry: None,
10691 }
10692 }
10693
10694 async fn hold_obligations(engine: &Engine, count: usize) -> Vec<String> {
10698 let mut roots = Vec::new();
10699 for n in 0..count {
10700 let record = bare_record(n as u32, 1 + n as u64, "retry", true);
10701 roots.push(record.outpoint.clone());
10702 engine.storage().put_deferred_graph(&record).await.unwrap();
10703 }
10704 roots
10705 }
10706
10707 #[tokio::test]
10715 async fn room_0110_a_full_store_still_records_the_cursors_holder_and_passes_it() {
10716 let UnanchorableFixture {
10717 engine,
10718 store,
10719 root,
10720 ..
10721 } = unanchorable_fixture();
10722 let host = "https://budget.example";
10723 let obligations =
10724 hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10725 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10726 for run in 1..=runs {
10727 let result = engine.start_gasp_sync().await.unwrap();
10728 let topic = &result.topics_synced["tm_test"];
10729 assert_eq!(topic.strikes_refused, 0, "run {run}: {topic:?}");
10730 let named = topic
10731 .faulted_roots
10732 .iter()
10733 .find(|f| f.outpoint == root)
10734 .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
10735 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10736 assert_eq!(named.faults, run, "run {run}: the strike was written");
10737 assert!(named.refused.is_empty(), "{named:?}");
10738 if run < runs {
10739 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10740 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10741 } else {
10742 assert!(named.unanchorable);
10743 assert_eq!(topic.unanchorable_graphs, 1);
10744 assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10745 assert_eq!(topic.cursor_moves[0].to, 30, "passed");
10746 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10747 }
10748 }
10749 let held: Vec<String> = store
10751 .deferred_graphs()
10752 .into_iter()
10753 .map(|r| r.outpoint)
10754 .collect();
10755 assert_eq!(held.len(), obligations.len(), "{held:?}");
10757 assert_eq!(store.fault_memos().len(), 1);
10758 assert!(obligations.iter().all(|o| held.contains(o)));
10759 let record = store
10760 .get_deferred_graph(host, "tm_test", &root)
10761 .await
10762 .unwrap()
10763 .unwrap();
10764 assert_eq!(record.reason, "unanchorable:courier");
10765 }
10766
10767 #[tokio::test]
10774 async fn room_0110_a_refused_strike_is_counted_and_named() {
10775 let UnanchorableFixture {
10776 engine,
10777 store,
10778 root,
10779 ..
10780 } = unanchorable_fixture();
10781 store.set_fault_memo_ceiling(Some(0));
10783 let refused_as = |result: &super::GASPSyncResult, why: &str| {
10784 let topic = &result.topics_synced["tm_test"];
10785 assert_eq!(topic.strikes_refused, 1, "{topic:?}");
10786 let named = topic
10787 .faulted_roots
10788 .iter()
10789 .find(|f| f.outpoint == root)
10790 .unwrap_or_else(|| panic!("{topic:?}"));
10791 assert_eq!(named.refused, why, "{named:?}");
10792 assert_eq!(named.faults, 0, "no record holds the count");
10793 assert!(topic.cursor_moves.is_empty(), "the gap holds");
10794 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10795 };
10796 let result = engine.start_gasp_sync().await.unwrap();
10797 refused_as(&result, crate::gasp::strike_refused::NO_ROOM);
10798 assert!(store
10799 .get_deferred_graph("https://budget.example", "tm_test", &root)
10800 .await
10801 .unwrap()
10802 .is_none());
10803
10804 store.set_fault_memo_ceiling(None);
10806 store.set_deferred_graph_put_fault(true);
10807 let result = engine.start_gasp_sync().await.unwrap();
10808 refused_as(&result, crate::gasp::strike_refused::STORE_FAULT);
10809
10810 store.set_deferred_graph_put_fault(false);
10812 let result = engine.start_gasp_sync().await.unwrap();
10813 let topic = &result.topics_synced["tm_test"];
10814 assert_eq!(topic.strikes_refused, 0, "{topic:?}");
10815 assert!(store
10816 .get_deferred_graph("https://budget.example", "tm_test", &root)
10817 .await
10818 .unwrap()
10819 .is_some());
10820 }
10821
10822 #[tokio::test]
10829 async fn room_0110_idle_obligations_back_off_and_the_page_is_walked() {
10830 let (mut engine, mut remote) = budget_page_engine(3);
10831 remote.node_cost_ms = 2000;
10832 let store = std::rc::Rc::new(MemoryStorage::new());
10833 engine.storage = Box::new(store.clone());
10834 virtual_peer_budget(&mut engine, &remote);
10835 engine.set_graph_budget(
10836 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10837 100,
10838 60_000,
10839 );
10840 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10841 let obligations =
10842 hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10843 let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10844 for run in 1..=idle {
10848 let result = engine.start_gasp_sync().await.unwrap();
10849 let topic = &result.topics_synced["tm_test"];
10850 assert_eq!(topic.finalized_graphs, 0, "run {run}: {topic:?}");
10851 assert!(topic.cursor_moves.is_empty(), "run {run}");
10852 assert_eq!(topic.resumed_graphs, 15, "run {run}");
10853 assert_eq!(topic.waiting_graphs, 0, "run {run}");
10854 let expect = if run == idle { 15 } else { 0 };
10855 assert_eq!(topic.backed_off_graphs, expect, "run {run}: {topic:?}");
10856 }
10857 let result = engine.start_gasp_sync().await.unwrap();
10859 let topic = &result.topics_synced["tm_test"];
10860 assert_eq!(topic.waiting_graphs, 15, "{topic:?}");
10861 assert_eq!(topic.finalized_graphs, 3, "the page's fresh roots");
10862 assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10863 assert_eq!(topic.cursor_moves[0].to, 30);
10864 let mut backed_off = 15;
10866 for _ in 0..idle {
10867 let result = engine.start_gasp_sync().await.unwrap();
10868 backed_off += result.topics_synced["tm_test"].backed_off_graphs;
10869 }
10870 assert_eq!(backed_off, obligations.len() as u64);
10871 let asked = remote.asked.borrow().len();
10872 let result = engine.start_gasp_sync().await.unwrap();
10873 let topic = &result.topics_synced["tm_test"];
10874 assert_eq!(topic.waiting_graphs, obligations.len() as u64, "{topic:?}");
10875 assert_eq!(topic.resumed_graphs, 0);
10876 assert!(topic.dropped_graphs.is_empty(), "{topic:?}");
10877 assert_eq!(remote.asked.borrow().len(), asked, "no request");
10878 let now = (current_timestamp_ms() / 1000.0) as u64;
10880 let held = store.deferred_graphs();
10881 assert_eq!(held.len(), obligations.len());
10882 for record in &held {
10883 assert!(obligations.contains(&record.outpoint));
10884 assert!(record.cursor_passable);
10885 assert!(record.idle_resumes >= idle, "{record:?}");
10886 assert!(record.retry_at > now, "{record:?}");
10887 }
10888 }
10889
10890 #[tokio::test]
10898 async fn room_0110_a_waiting_record_whose_root_expired_is_swept() {
10899 let (mut engine, remote) = budget_page_engine(1);
10900 let store = std::rc::Rc::new(MemoryStorage::new());
10901 engine.storage = Box::new(store.clone());
10902 engine.set_graph_budget(
10903 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10904 100,
10905 60_000,
10906 );
10907 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10908 let now = (current_timestamp_ms() / 1000.0) as u64;
10909 let mut waiting = bare_record(0, 1, "retry", true);
10910 waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10911 waiting.retry_at = now + 3600;
10912 let dead = bare_record(1, 2, "retry", true);
10913 let mut dead_waiting = dead.clone();
10914 dead_waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10915 dead_waiting.retry_at = now + 3600;
10916 for record in [&waiting, &dead_waiting] {
10917 engine.storage().put_deferred_graph(record).await.unwrap();
10918 }
10919 engine
10920 .storage()
10921 .remember_expired_outpoint("tm_test", &dead.outpoint, 1)
10922 .await
10923 .unwrap();
10924 let result = engine.start_gasp_sync().await.unwrap();
10925 let topic = &result.topics_synced["tm_test"];
10926 assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
10927 assert_eq!(
10928 topic
10929 .dropped_graphs
10930 .iter()
10931 .map(|d| (d.outpoint.as_str(), d.reason.as_str()))
10932 .collect::<Vec<_>>(),
10933 vec![(dead.outpoint.as_str(), "refused")]
10934 );
10935 let held: Vec<String> = store
10936 .deferred_graphs()
10937 .into_iter()
10938 .map(|r| r.outpoint)
10939 .collect();
10940 assert_eq!(held, vec![waiting.outpoint.clone()]);
10941 let asked = remote.asked.borrow();
10942 assert!(!asked.iter().any(|t| dead.outpoint.starts_with(t.as_str())));
10943 assert!(!asked
10944 .iter()
10945 .any(|t| waiting.outpoint.starts_with(t.as_str())));
10946 }
10947
10948 #[tokio::test]
10951 async fn room_0110_a_resume_that_appends_a_node_resets_the_count() {
10952 let UnanchorableFixture {
10953 mut engine,
10954 remote,
10955 store,
10956 root,
10957 ..
10958 } = unanchorable_fixture();
10959 engine.set_graph_budget_limbs(u64::MAX, 1);
10961 engine.set_gasp_remote_factory(Box::new(remote));
10962 let mut record = bare_record(0, 10, "retry", true);
10963 record.outpoint.clone_from(&root);
10964 record.pending[0].outpoint.clone_from(&root);
10965 record.pending[0].graph_id.clone_from(&root);
10966 record.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF - 1;
10967 engine.storage().put_deferred_graph(&record).await.unwrap();
10968 let result = engine.start_gasp_sync().await.unwrap();
10969 let topic = &result.topics_synced["tm_test"];
10970 assert_eq!(topic.resumed_graphs, 1, "{topic:?}");
10971 assert_eq!(topic.backed_off_graphs, 0);
10972 let held = store
10973 .get_deferred_graph("https://budget.example", "tm_test", &root)
10974 .await
10975 .unwrap()
10976 .unwrap();
10977 assert_eq!(held.nodes.len(), 1, "{held:?}");
10978 assert_eq!(held.reason, "nodes");
10979 assert_eq!(held.idle_resumes, 0);
10980 assert_eq!(held.retry_at, 0);
10981 }
10982
10983 struct ExhaustedCourier {
10987 asked: std::rc::Rc<Cell<u64>>,
10988 transient: std::rc::Rc<Cell<bool>>,
10990 }
10991
10992 #[async_trait(?Send)]
10993 impl AncestorFetcher for ExhaustedCourier {
10994 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10995 self.asked.set(self.asked.get() + 1);
10996 if self.transient.get() {
10997 return Err(GASPError::RemoteError(format!(
10998 "rung rate limited for {txid}"
10999 )));
11000 }
11001 Err(GASPError::NodeNotFound(format!(
11002 "no raw tx for {txid} (bitails + woc + bananablocks exhausted)"
11003 )))
11004 }
11005 async fn verified_proof_for(&self, _: &str) -> Option<String> {
11006 None
11007 }
11008 }
11009
11010 struct ObligationFixture {
11016 engine: Engine,
11017 remote: BudgetPageRemote,
11018 store: std::rc::Rc<MemoryStorage>,
11019 obligations: Vec<String>,
11020 courier: std::rc::Rc<Cell<u64>>,
11021 transient: std::rc::Rc<Cell<bool>>,
11022 }
11023
11024 async fn obligation_fixture(page: u32, count: u32, node_cost_ms: u64) -> ObligationFixture {
11025 let (mut engine, mut remote) = budget_page_engine(page);
11026 remote.node_cost_ms = node_cost_ms;
11027 let mut obligations = Vec::new();
11028 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
11029 let mut records = Vec::new();
11030 for n in 0..count {
11031 let parent = child_of(&format!("{:064x}", 0xbeef_0000 + n), 0);
11032 let child = child_of(&parent.id(), 0);
11033 remote.faults_for.borrow_mut().insert(parent.id());
11038 nodes.insert(
11039 child.id(),
11040 crate::types::GASPNode {
11041 graph_id: String::new(),
11042 raw_tx: child.to_hex(),
11043 output_index: 0,
11044 proof: None,
11045 tx_metadata: None,
11046 output_metadata: None,
11047 inputs: None,
11048 },
11049 );
11050 let mut record = bare_record(n, 1 + u64::from(n), "retry", true);
11051 let outpoint = format!("{}.0", child.id());
11052 record.outpoint.clone_from(&outpoint);
11053 record.pending[0].outpoint.clone_from(&outpoint);
11054 record.pending[0].graph_id.clone_from(&outpoint);
11055 obligations.push(outpoint);
11056 records.push(record);
11057 }
11058 let courier = std::rc::Rc::new(Cell::new(0));
11059 let transient = std::rc::Rc::new(Cell::new(false));
11060 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
11061 asked: courier.clone(),
11062 transient: transient.clone(),
11063 }));
11064 let store = std::rc::Rc::new(MemoryStorage::new());
11065 engine.storage = Box::new(store.clone());
11066 virtual_peer_budget(&mut engine, &remote);
11067 engine.set_graph_budget(
11068 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
11069 100,
11070 60_000,
11071 );
11072 engine.set_gasp_remote_factory(Box::new(remote.clone()));
11073 for record in &records {
11074 let json = serde_json::to_value(record).unwrap();
11076 assert!(json.get("idleResumes").is_none() && json.get("faults").is_none());
11077 engine.storage().put_deferred_graph(record).await.unwrap();
11078 }
11079 ObligationFixture {
11080 engine,
11081 remote,
11082 store,
11083 obligations,
11084 courier,
11085 transient,
11086 }
11087 }
11088
11089 #[tokio::test]
11098 async fn obligations_0111_a_faulted_resume_is_struck_and_the_page_is_walked() {
11099 let ObligationFixture {
11100 engine,
11101 remote,
11102 store,
11103 obligations,
11104 courier,
11105 ..
11106 } = obligation_fixture(3, 1, 0).await;
11107 let host = "https://budget.example";
11108 let held = &obligations[0];
11109 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
11110 for run in 1..=runs {
11111 let result = engine.start_gasp_sync().await.unwrap();
11112 let topic = &result.topics_synced["tm_test"];
11113 let named = topic
11114 .faulted_roots
11115 .iter()
11116 .find(|f| &f.outpoint == held)
11117 .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11118 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
11119 assert!(named.error.contains("exhausted"), "{named:?}");
11120 assert_eq!(named.faults, run, "run {run}: struck on its own slot");
11121 assert!(named.refused.is_empty(), "{named:?}");
11122 assert_eq!(topic.strikes_refused, 0);
11123 assert!(topic.cursor_held_by.is_empty(), "run {run}: {topic:?}");
11124 if run == 1 {
11125 assert_eq!(topic.finalized_graphs, 3, "the page, the same run");
11126 assert_eq!(topic.cursor_moves[0].to, 30);
11127 }
11128 let record = store
11129 .get_deferred_graph(host, "tm_test", held)
11130 .await
11131 .unwrap()
11132 .unwrap();
11133 assert_eq!(record.faults, run, "{record:?}");
11134 assert!(record.cursor_passable, "run {run}: never holds the cursor");
11135 assert!(record.nodes.is_empty());
11136 if run < runs {
11137 assert!(!named.unanchorable);
11138 assert_eq!(record.reason, "retry:fault:courier");
11139 assert_eq!(topic.unanchorable_graphs, 0);
11140 } else {
11141 assert!(named.unanchorable);
11142 assert_eq!(record.reason, "unanchorable:courier");
11143 assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
11144 assert_eq!(topic.unanchorable_graphs, 1, "{topic:?}");
11145 }
11146 }
11147 let (asked, couriered) = (remote.asked.borrow().len(), courier.get());
11150 let result = engine.start_gasp_sync().await.unwrap();
11151 let topic = &result.topics_synced["tm_test"];
11152 assert!(topic.faulted_roots.is_empty(), "{topic:?}");
11153 assert_eq!(topic.resumed_graphs, 0);
11154 assert_eq!(remote.asked.borrow().len(), asked);
11155 assert_eq!(courier.get(), couriered);
11156 assert!(store.deferred_graphs().is_empty());
11158 assert_eq!(store.fault_memos().len(), 1);
11159 }
11160
11161 #[tokio::test]
11173 async fn obligations_0111_a_resume_that_appends_nothing_counts_on_every_ending() {
11174 let host = "https://budget.example";
11175 let fixture = obligation_fixture(1, 1, 0).await;
11178 fixture.store.set_fault_memo_ceiling(Some(0));
11179 let held = &fixture.obligations[0];
11180 let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
11181 for run in 1..=idle {
11182 let result = fixture.engine.start_gasp_sync().await.unwrap();
11183 let topic = &result.topics_synced["tm_test"];
11184 let record = fixture
11185 .store
11186 .get_deferred_graph(host, "tm_test", held)
11187 .await
11188 .unwrap()
11189 .unwrap();
11190 assert_eq!(record.idle_resumes, run, "run {run}: {record:?} {topic:?}");
11191 assert_eq!(record.reason, "retry", "the drop stands");
11192 assert!(record.cursor_passable && record.nodes.is_empty());
11193 assert_eq!(topic.idle_counted, 1, "run {run}: {topic:?}");
11194 let named = topic
11195 .faulted_roots
11196 .iter()
11197 .find(|f| &f.outpoint == held)
11198 .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11199 assert_eq!(named.refused, crate::gasp::strike_refused::NO_ROOM);
11200 assert_eq!(named.faults, 0);
11201 assert_eq!(topic.strikes_refused, 1);
11202 assert_eq!(topic.backed_off_graphs, u64::from(run == idle));
11203 }
11204 let result = fixture.engine.start_gasp_sync().await.unwrap();
11205 let topic = &result.topics_synced["tm_test"];
11206 assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
11207 assert_eq!(topic.idle_counted, 0);
11208
11209 let mut fixture = obligation_fixture(1, 1, 0).await;
11211 let gone = fixture.obligations[0].clone();
11212 std::rc::Rc::make_mut(&mut fixture.remote.nodes).remove(gone.strip_suffix(".0").unwrap());
11213 fixture
11214 .engine
11215 .set_gasp_remote_factory(Box::new(fixture.remote.clone()));
11216 let result = fixture.engine.start_gasp_sync().await.unwrap();
11217 let topic = &result.topics_synced["tm_test"];
11218 assert_eq!(topic.idle_counted, 1, "{topic:?}");
11219 assert_eq!(
11220 topic
11221 .dropped_graphs
11222 .iter()
11223 .map(|d| d.reason.as_str())
11224 .collect::<Vec<_>>(),
11225 vec!["not_held"]
11226 );
11227 let record = fixture
11228 .store
11229 .get_deferred_graph(host, "tm_test", &gone)
11230 .await
11231 .unwrap()
11232 .unwrap();
11233 assert_eq!((record.idle_resumes, record.faults), (1, 0), "{record:?}");
11234
11235 let fixture = obligation_fixture(1, 1, 0).await;
11239 fixture.transient.set(true);
11240 let kept = &fixture.obligations[0];
11241 let result = fixture.engine.start_gasp_sync().await.unwrap();
11242 assert_eq!(result.topics_synced["tm_test"].idle_counted, 0, "progress");
11243 let result = fixture.engine.start_gasp_sync().await.unwrap();
11244 let topic = &result.topics_synced["tm_test"];
11245 assert_eq!(topic.idle_counted, 1, "{topic:?}");
11246 let record = fixture
11247 .store
11248 .get_deferred_graph(host, "tm_test", kept)
11249 .await
11250 .unwrap()
11251 .unwrap();
11252 assert_eq!(record.reason, "fault");
11253 assert_eq!(
11254 (record.nodes.len(), record.idle_resumes),
11255 (1, 1),
11256 "{record:?}"
11257 );
11258 }
11259
11260 #[tokio::test]
11271 async fn obligations_0111_fourteen_obligations_leave_the_page_its_share() {
11272 let ObligationFixture {
11273 mut engine,
11274 remote,
11275 store,
11276 obligations,
11277 ..
11278 } = obligation_fixture(12, 14, 1500).await;
11279 let elapsed = remote.elapsed.clone();
11280 engine.set_graph_budget(
11281 std::rc::Rc::new(move |ms| {
11282 let elapsed = elapsed.clone();
11283 let until = elapsed.get() + ms;
11284 Box::pin(std::future::poll_fn(move |_| {
11285 if elapsed.get() >= until {
11286 std::task::Poll::Ready(())
11287 } else {
11288 std::task::Poll::Pending
11289 }
11290 }))
11291 }),
11292 100,
11293 15_000,
11294 );
11295 assert_eq!(crate::gasp::resume_share_ms(30_000, 15_000), 9_900);
11296 let result = engine.start_gasp_sync().await.unwrap();
11297 let topic = &result.topics_synced["tm_test"];
11298 assert!(topic.errors.is_empty(), "inside the slice: {topic:?}");
11299 assert_eq!(topic.finalized_graphs, 12, "the page's share: {topic:?}");
11300 assert_eq!(topic.cursor_moves[0].to, 120);
11301 assert_eq!(topic.resumed_graphs, 4, "{topic:?}");
11308 assert_eq!(topic.held_back_graphs, 10, "{topic:?}");
11309 assert_eq!(topic.faulted_roots.len(), 3, "{topic:?}");
11310 assert_eq!((topic.stalled_graphs, topic.idle_counted), (1, 1));
11311 assert!(topic.cursor_held_by.is_empty());
11312 let host = "https://budget.example";
11313 let mut reasons = std::collections::BTreeMap::new();
11314 for outpoint in &obligations {
11315 let record = store
11316 .get_deferred_graph(host, "tm_test", outpoint)
11317 .await
11318 .unwrap()
11319 .unwrap();
11320 assert!(record.cursor_passable, "{record:?}");
11321 if record.reason == "retry" {
11322 assert_eq!((record.idle_resumes, record.retry_at), (0, 0));
11324 assert_eq!((record.faults, record.passes), (0, 0));
11325 }
11326 *reasons.entry(record.reason).or_insert(0) += 1;
11327 }
11328 assert_eq!(
11329 reasons,
11330 std::collections::BTreeMap::from([
11331 ("retry".to_string(), 10),
11332 ("retry:fault:courier".to_string(), 3),
11333 ("time".to_string(), 1),
11334 ])
11335 );
11336 }
11337
11338 #[tokio::test]
11339 async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
11340 let (mut engine, mut remote) = budget_page_engine(1);
11341 let txid = remote.outputs[0].txid.clone();
11342 std::rc::Rc::make_mut(&mut remote.nodes)
11343 .get_mut(&txid)
11344 .unwrap()
11345 .proof = None;
11346 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
11347 proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
11348 requested: std::rc::Rc::default(),
11349 }));
11350 virtual_peer_budget(&mut engine, &remote);
11351 engine.set_gasp_remote_factory(Box::new(remote));
11352 let result = engine.start_gasp_sync().await.unwrap();
11353 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
11354 assert!(engine
11355 .storage
11356 .find_output(&txid, 0, Some("tm_test"), None, true)
11357 .await
11358 .unwrap()
11359 .is_none());
11360 }
11361
11362 impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
11363 fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11364 Box::new(MockSyncRemote {
11365 utxos: vec![
11366 crate::types::GASPOutput {
11367 txid: "remote_tx1".to_string(),
11368 output_index: 0,
11369 score: 100.0,
11370 },
11371 crate::types::GASPOutput {
11372 txid: "remote_tx2".to_string(),
11373 output_index: 0,
11374 score: 200.0,
11375 },
11376 ],
11377 })
11378 }
11379 }
11380
11381 #[tokio::test]
11382 async fn test_start_gasp_sync_with_factory_runs_sync() {
11383 let mut sync_config: SyncConfiguration = HashMap::new();
11384 sync_config.insert(
11385 "tm_test".to_string(),
11386 SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
11387 );
11388
11389 let mut engine = Engine::new(
11390 HashMap::new(),
11391 HashMap::new(),
11392 Box::new(MemoryStorage::new()),
11393 None,
11394 EngineConfig {
11395 sync_configuration: sync_config,
11396 ..Default::default()
11397 },
11398 );
11399
11400 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11401
11402 let result = engine.start_gasp_sync().await.unwrap();
11403 assert_eq!(result.topics_synced.len(), 1);
11404
11405 let topic_result = &result.topics_synced["tm_test"];
11406 assert_eq!(topic_result.peers.len(), 1);
11407 assert!(
11408 topic_result.errors.is_empty(),
11409 "Sync should succeed with mock remote"
11410 );
11411
11412 let last = engine
11414 .storage()
11415 .get_last_interaction("https://peer1.example.com", "tm_test")
11416 .await
11417 .unwrap();
11418 assert_eq!(
11421 last, 0,
11422 "cursor must remain at entry when no output completed"
11423 );
11424 }
11425
11426 #[tokio::test]
11427 async fn test_start_gasp_sync_with_factory_handles_error() {
11428 struct FailingFactory;
11429
11430 impl crate::gasp::GASPRemoteFactory for FailingFactory {
11431 fn create_remote(
11432 &self,
11433 _peer_url: &str,
11434 _topic: &str,
11435 ) -> Box<dyn crate::gasp::GASPRemote> {
11436 struct FailingRemote;
11437 #[async_trait(?Send)]
11438 impl crate::gasp::GASPRemote for FailingRemote {
11439 async fn get_initial_response(
11440 &self,
11441 _: &crate::types::GASPInitialRequest,
11442 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
11443 {
11444 Err(crate::gasp::GASPError::RemoteError(
11445 "connection refused".into(),
11446 ))
11447 }
11448 async fn get_initial_reply(
11449 &self,
11450 _: &crate::types::GASPInitialResponse,
11451 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
11452 {
11453 unreachable!()
11454 }
11455 async fn request_node(
11456 &self,
11457 _: &str,
11458 _: &str,
11459 _: u32,
11460 _: bool,
11461 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
11462 {
11463 unreachable!()
11464 }
11465 async fn submit_node(
11466 &self,
11467 _: &crate::types::GASPNode,
11468 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
11469 {
11470 unreachable!()
11471 }
11472 }
11473 Box::new(FailingRemote)
11474 }
11475 }
11476
11477 let mut sync_config: SyncConfiguration = HashMap::new();
11478 sync_config.insert(
11479 "tm_test".to_string(),
11480 SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
11481 );
11482
11483 let mut engine = Engine::new(
11484 HashMap::new(),
11485 HashMap::new(),
11486 Box::new(MemoryStorage::new()),
11487 None,
11488 EngineConfig {
11489 sync_configuration: sync_config,
11490 ..Default::default()
11491 },
11492 );
11493
11494 engine.set_gasp_remote_factory(Box::new(FailingFactory));
11495
11496 let result = engine.start_gasp_sync().await.unwrap();
11497 assert_eq!(result.topics_synced.len(), 1);
11498
11499 let topic_result = &result.topics_synced["tm_test"];
11500 assert_eq!(
11501 topic_result.errors.len(),
11502 1,
11503 "Should have one error for the failing peer"
11504 );
11505 assert!(topic_result.errors[0].contains("connection refused"));
11506 }
11507
11508 #[tokio::test]
11509 async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
11510 let mut sync_config: SyncConfiguration = HashMap::new();
11511 sync_config.insert(
11512 "tm_test".to_string(),
11513 SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
11514 );
11515
11516 let engine = Engine::new(
11518 HashMap::new(),
11519 HashMap::new(),
11520 Box::new(MemoryStorage::new()),
11521 None,
11522 EngineConfig {
11523 sync_configuration: sync_config,
11524 ..Default::default()
11525 },
11526 );
11527
11528 let result = engine.start_gasp_sync().await.unwrap();
11529 assert_eq!(result.topics_synced.len(), 1);
11530
11531 let topic_result = &result.topics_synced["tm_test"];
11532 assert_eq!(topic_result.peers.len(), 1);
11533 assert!(topic_result.errors.is_empty());
11534 }
11535
11536 #[tokio::test]
11537 async fn test_start_gasp_sync_with_factory_multiple_peers() {
11538 let mut sync_config: SyncConfiguration = HashMap::new();
11539 sync_config.insert(
11540 "tm_test".to_string(),
11541 SyncTarget::Peers(vec![
11542 "https://peer1.example.com".to_string(),
11543 "https://peer2.example.com".to_string(),
11544 ]),
11545 );
11546
11547 let mut engine = Engine::new(
11548 HashMap::new(),
11549 HashMap::new(),
11550 Box::new(MemoryStorage::new()),
11551 None,
11552 EngineConfig {
11553 sync_configuration: sync_config,
11554 ..Default::default()
11555 },
11556 );
11557
11558 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11559
11560 let result = engine.start_gasp_sync().await.unwrap();
11561 let topic_result = &result.topics_synced["tm_test"];
11562 assert_eq!(topic_result.peers.len(), 2);
11563 assert!(topic_result.errors.is_empty());
11564
11565 let last1 = engine
11567 .storage()
11568 .get_last_interaction("https://peer1.example.com", "tm_test")
11569 .await
11570 .unwrap();
11571 let last2 = engine
11572 .storage()
11573 .get_last_interaction("https://peer2.example.com", "tm_test")
11574 .await
11575 .unwrap();
11576 assert_eq!(last1, 0);
11577 assert_eq!(last2, 0);
11578 }
11579
11580 struct HangingRemote;
11586
11587 #[async_trait(?Send)]
11588 impl crate::gasp::GASPRemote for HangingRemote {
11589 async fn get_initial_response(
11590 &self,
11591 _: &crate::types::GASPInitialRequest,
11592 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
11593 std::future::pending::<()>().await;
11594 unreachable!()
11595 }
11596 async fn get_initial_reply(
11597 &self,
11598 _: &crate::types::GASPInitialResponse,
11599 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
11600 unreachable!()
11601 }
11602 async fn request_node(
11603 &self,
11604 _: &str,
11605 _: &str,
11606 _: u32,
11607 _: bool,
11608 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
11609 unreachable!()
11610 }
11611 async fn submit_node(
11612 &self,
11613 _: &crate::types::GASPNode,
11614 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
11615 unreachable!()
11616 }
11617 }
11618
11619 struct SelectiveHangFactory {
11623 created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
11624 }
11625
11626 impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
11627 fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11628 self.created.borrow_mut().push(peer_url.to_string());
11629 if peer_url.contains("hang.example.com") {
11630 Box::new(HangingRemote)
11631 } else {
11632 Box::new(MockSyncRemote {
11633 utxos: vec![crate::types::GASPOutput {
11634 txid: "remote_tx1".to_string(),
11635 output_index: 0,
11636 score: 100.0,
11637 }],
11638 })
11639 }
11640 }
11641 }
11642
11643 fn instant_deadline() -> crate::engine::SleepFactory {
11648 std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
11649 }
11650
11651 fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
11652 let mut sync_config: SyncConfiguration = HashMap::new();
11653 sync_config.insert(
11654 "tm_test".to_string(),
11655 SyncTarget::Peers(vec![
11656 "https://hang.example.com".to_string(),
11657 "https://good.example.com".to_string(),
11658 ]),
11659 );
11660 Engine::new(
11661 HashMap::new(),
11662 HashMap::new(),
11663 Box::new(store),
11664 None,
11665 EngineConfig {
11666 sync_configuration: sync_config,
11667 ..Default::default()
11668 },
11669 )
11670 }
11671
11672 #[tokio::test]
11673 async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
11674 let store = std::rc::Rc::new(MemoryStorage::new());
11675 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11676 let mut engine = two_peer_engine(store.clone());
11677 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11678 created: created.clone(),
11679 }));
11680 engine.set_peer_sync_budget(instant_deadline(), 1);
11681
11682 let result = engine.start_gasp_sync().await.unwrap();
11683 let topic_result = &result.topics_synced["tm_test"];
11684
11685 assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
11688 assert!(
11689 topic_result.errors[0].contains("budget"),
11690 "{:?}",
11691 topic_result.errors
11692 );
11693 assert_eq!(
11694 created.borrow().as_slice(),
11695 ["https://hang.example.com", "https://good.example.com"],
11696 "both peers attempted this tick"
11697 );
11698
11699 let hang_cursor = store
11702 .get_last_interaction("https://hang.example.com", "tm_test")
11703 .await
11704 .unwrap();
11705 assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
11706 let good_cursor = store
11707 .get_last_interaction("https://good.example.com", "tm_test")
11708 .await
11709 .unwrap();
11710 assert_eq!(good_cursor, 0, "no synthetic graph completed");
11711
11712 let hang_health = store
11714 .get_peer_sync_health("hang.example.com", "tm_test")
11715 .await
11716 .unwrap();
11717 assert_eq!(hang_health.consecutive_failures, 1);
11718 let good_health = store
11719 .get_peer_sync_health("good.example.com", "tm_test")
11720 .await
11721 .unwrap();
11722 assert_eq!(good_health.consecutive_failures, 0);
11723 }
11724
11725 #[tokio::test]
11726 async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
11727 let store = std::rc::Rc::new(MemoryStorage::new());
11728 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11729
11730 let mut sync_config: SyncConfiguration = HashMap::new();
11731 sync_config.insert(
11732 "tm_test".to_string(),
11733 SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
11734 );
11735 let mut engine = Engine::new(
11736 HashMap::new(),
11737 HashMap::new(),
11738 Box::new(store.clone()),
11739 None,
11740 EngineConfig {
11741 sync_configuration: sync_config,
11742 ..Default::default()
11743 },
11744 );
11745 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11746 created: created.clone(),
11747 }));
11748 engine.set_peer_sync_budget(instant_deadline(), 1);
11749
11750 for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
11754 engine.start_gasp_sync().await.unwrap();
11755 store.advance_clock(900);
11756 assert_eq!(
11757 created.borrow().len() as u64,
11758 i + 1,
11759 "peer attempted while below the threshold"
11760 );
11761 }
11762 let health = store
11763 .get_peer_sync_health("hang.example.com", "tm_test")
11764 .await
11765 .unwrap();
11766 assert_eq!(
11767 health.consecutive_failures,
11768 crate::gasp::PEER_QUARANTINE_THRESHOLD
11769 );
11770
11771 let result = engine.start_gasp_sync().await.unwrap();
11775 assert!(result.topics_synced["tm_test"].errors.is_empty());
11776 assert_eq!(
11777 created.borrow().len() as u64,
11778 crate::gasp::PEER_QUARANTINE_THRESHOLD,
11779 "quarantined peer must be skipped"
11780 );
11781
11782 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11784 engine.start_gasp_sync().await.unwrap();
11785 assert_eq!(
11786 created.borrow().len() as u64,
11787 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
11788 "re-probe attempted after the window"
11789 );
11790 let health = store
11792 .get_peer_sync_health("hang.example.com", "tm_test")
11793 .await
11794 .unwrap();
11795 assert_eq!(
11796 health.consecutive_failures,
11797 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
11798 );
11799 let result = engine.start_gasp_sync().await.unwrap();
11800 assert!(
11801 result.topics_synced["tm_test"].errors.is_empty(),
11802 "re-quarantined"
11803 );
11804
11805 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11809 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11811 let result = engine.start_gasp_sync().await.unwrap();
11812 assert!(result.topics_synced["tm_test"].errors.is_empty());
11813 let health = store
11814 .get_peer_sync_health("hang.example.com", "tm_test")
11815 .await
11816 .unwrap();
11817 assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
11818 }
11819
11820 use crate::broadcaster::ArcBroadcaster;
11823
11824 struct MockArcBroadcaster {
11825 calls: Arc<Mutex<Vec<String>>>,
11826 should_fail: bool,
11827 }
11828
11829 impl MockArcBroadcaster {
11830 fn new() -> Self {
11831 Self {
11832 calls: Arc::new(Mutex::new(Vec::new())),
11833 should_fail: false,
11834 }
11835 }
11836
11837 fn failing() -> Self {
11838 Self {
11839 calls: Arc::new(Mutex::new(Vec::new())),
11840 should_fail: true,
11841 }
11842 }
11843 }
11844
11845 #[async_trait(?Send)]
11846 impl ArcBroadcaster for MockArcBroadcaster {
11847 async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
11848 self.calls.lock().unwrap().push(raw_tx_hex.to_string());
11849 if self.should_fail {
11850 Err("mock ARC failure".to_string())
11851 } else {
11852 Ok("mock_txid_from_arc".to_string())
11853 }
11854 }
11855 }
11856
11857 fn make_engine_with_arc(
11859 admit_indices: Vec<u32>,
11860 arc: MockArcBroadcaster,
11861 ) -> (Engine, Arc<Mutex<Vec<String>>>) {
11862 let arc_calls = arc.calls.clone();
11863
11864 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11865 managers.insert(
11866 "tm_test".to_string(),
11867 Box::new(MockTopicManager::admitting(admit_indices)),
11868 );
11869
11870 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
11871 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
11872
11873 let storage = Box::new(MemoryStorage::new());
11874
11875 let engine = Engine::with_all(
11876 managers,
11877 lookup_services,
11878 storage,
11879 None,
11880 None,
11881 Some(Box::new(arc)),
11882 None,
11883 EngineConfig::default(),
11884 );
11885
11886 (engine, arc_calls)
11887 }
11888
11889 #[tokio::test]
11892 async fn test_arc_broadcaster_called_on_current_tx() {
11893 let arc = MockArcBroadcaster::new();
11894 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11895
11896 let beef = test_tagged_beef(vec!["tm_test"]);
11897 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11898
11899 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11900
11901 let calls = arc_calls.lock().unwrap();
11902 assert_eq!(
11903 calls.len(),
11904 1,
11905 "ARC broadcaster should be called once for CurrentTx"
11906 );
11907 assert!(
11908 !calls[0].is_empty(),
11909 "ARC should receive non-empty raw tx hex"
11910 );
11911 }
11912
11913 #[tokio::test]
11914 async fn test_arc_broadcaster_not_called_on_historical_tx() {
11915 let arc = MockArcBroadcaster::new();
11916 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11917
11918 let beef = test_tagged_beef(vec!["tm_test"]);
11919 engine
11920 .submit(&beef, SubmitMode::HistoricalTx)
11921 .await
11922 .unwrap();
11923
11924 assert_eq!(
11925 arc_calls.lock().unwrap().len(),
11926 0,
11927 "ARC broadcaster should NOT be called for HistoricalTx"
11928 );
11929 }
11930
11931 #[tokio::test]
11932 async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
11933 let arc = MockArcBroadcaster::new();
11934 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11935
11936 let beef = test_tagged_beef(vec!["tm_test"]);
11937 engine
11938 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
11939 .await
11940 .unwrap();
11941
11942 assert_eq!(
11943 arc_calls.lock().unwrap().len(),
11944 0,
11945 "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
11946 );
11947 }
11948
11949 #[tokio::test]
11950 async fn test_arc_broadcast_failure_does_not_fail_submit() {
11951 let arc = MockArcBroadcaster::failing();
11952 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11953
11954 let beef = test_tagged_beef(vec!["tm_test"]);
11955 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11957
11958 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11959
11960 assert_eq!(
11962 arc_calls.lock().unwrap().len(),
11963 1,
11964 "ARC broadcaster should be attempted even if it will fail"
11965 );
11966 }
11967
11968 #[tokio::test]
11969 async fn test_arc_broadcaster_not_present_still_works() {
11970 let engine = make_engine(vec![0]);
11972
11973 let beef = test_tagged_beef(vec!["tm_test"]);
11974 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11975
11976 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11977 }
11978
11979 struct TrackingAdvertiser {
11983 created: Arc<Mutex<Vec<AdvertisementData>>>,
11984 existing_ship: Vec<Advertisement>,
11985 existing_slap: Vec<Advertisement>,
11986 tag_topics: Vec<String>,
11990 create_fails: bool,
11992 find_fails: bool,
11994 revoke_empty: bool,
11996 }
11997
11998 impl TrackingAdvertiser {
11999 fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
12000 let created = Arc::new(Mutex::new(Vec::new()));
12001 (
12002 Self {
12003 created: created.clone(),
12004 existing_ship: vec![],
12005 existing_slap: vec![],
12006 tag_topics: vec!["tm_test".to_string()],
12007 create_fails: false,
12008 find_fails: false,
12009 revoke_empty: false,
12010 },
12011 created,
12012 )
12013 }
12014 }
12015
12016 use crate::advertiser::{Advertiser, AdvertiserError};
12017
12018 #[async_trait(?Send)]
12019 impl Advertiser for TrackingAdvertiser {
12020 async fn create_advertisements(
12021 &self,
12022 ads: &[AdvertisementData],
12023 ) -> Result<TaggedBEEF, AdvertiserError> {
12024 if self.create_fails {
12025 return Err(AdvertiserError::CreationFailed(
12026 "mock create refused".into(),
12027 ));
12028 }
12029 self.created.lock().unwrap().extend(ads.iter().cloned());
12030 Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
12031 }
12032
12033 async fn find_all_advertisements(
12034 &self,
12035 protocol: Protocol,
12036 ) -> Result<Vec<Advertisement>, AdvertiserError> {
12037 if self.find_fails {
12038 return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
12039 }
12040 match protocol {
12041 Protocol::Ship => Ok(self.existing_ship.clone()),
12042 Protocol::Slap => Ok(self.existing_slap.clone()),
12043 }
12044 }
12045
12046 async fn revoke_advertisements(
12047 &self,
12048 _ads: &[Advertisement],
12049 ) -> Result<TaggedBEEF, AdvertiserError> {
12050 if self.revoke_empty {
12051 return Ok(TaggedBEEF::new(vec![], vec![]));
12052 }
12053 Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
12054 }
12055
12056 fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
12057 None
12058 }
12059 }
12060
12061 #[tokio::test]
12065 async fn test_sync_advertisements_url_validation() {
12066 let (adv, created) = TrackingAdvertiser::new();
12068
12069 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12070 managers.insert(
12071 "tm_test".to_string(),
12072 Box::new(MockTopicManager::admitting(vec![0])),
12073 );
12074
12075 let engine = Engine::new(
12076 managers,
12077 HashMap::new(),
12078 Box::new(MemoryStorage::new()),
12079 Some(Box::new(adv)),
12080 EngineConfig {
12081 hosting_url: Some(String::new()), ..Default::default()
12083 },
12084 );
12085
12086 engine.sync_advertisements().await.unwrap();
12087 assert!(
12088 created.lock().unwrap().is_empty(),
12089 "Empty hosting URL should skip advertisement creation"
12090 );
12091
12092 let (adv2, created2) = TrackingAdvertiser::new();
12094
12095 let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12096 managers2.insert(
12097 "tm_test".to_string(),
12098 Box::new(MockTopicManager::admitting(vec![0])),
12099 );
12100 managers2.insert(
12102 "tm_ship".to_string(),
12103 Box::new(MockTopicManager::admitting(vec![])),
12104 );
12105
12106 let engine2 = Engine::new(
12107 managers2,
12108 HashMap::new(),
12109 Box::new(MemoryStorage::new()),
12110 Some(Box::new(adv2)),
12111 EngineConfig {
12112 hosting_url: Some("https://valid.example.com".to_string()),
12113 suppress_default_sync_advertisements: true,
12114 ..Default::default()
12115 },
12116 );
12117
12118 engine2.sync_advertisements().await.unwrap();
12119 let ads = created2.lock().unwrap();
12120 assert!(
12122 ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
12123 "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
12124 );
12125 assert!(
12126 ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
12127 "tm_test should get a SHIP advertisement"
12128 );
12129 }
12130
12131 fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
12140 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12141 managers.insert(
12142 "tm_test".to_string(),
12143 Box::new(MockTopicManager::admitting(vec![0])),
12144 );
12145 Engine::new(
12146 managers,
12147 HashMap::new(),
12148 Box::new(MemoryStorage::new()),
12149 Some(Box::new(adv)),
12150 EngineConfig {
12151 hosting_url: Some("https://valid.example.com".to_string()),
12152 suppress_default_sync_advertisements: true,
12153 ..Default::default()
12154 },
12155 )
12156 }
12157
12158 #[tokio::test]
12159 async fn sync_report_counts_admitted_outputs_on_success() {
12160 let (adv, _) = TrackingAdvertiser::new();
12161 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12162 assert!(report.ok(), "clean run must report ok: {report:?}");
12163 assert_eq!(report.to_create, 1, "tm_test was missing");
12164 assert_eq!(
12165 report.admitted.get("tm_test"),
12166 Some(&1),
12167 "the STEAK's admitted count must be surfaced: {report:?}"
12168 );
12169 }
12170
12171 #[tokio::test]
12172 async fn sync_report_surfaces_local_submit_failure() {
12173 let (mut adv, _) = TrackingAdvertiser::new();
12177 adv.tag_topics = vec!["tm_unregistered".to_string()];
12178 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12179 assert!(!report.ok(), "a failed local submit must not report ok");
12180 assert_eq!(report.to_create, 1);
12181 let submit_error = report.submit_error.as_deref().unwrap_or_default();
12182 assert!(
12183 submit_error.contains("tm_unregistered"),
12184 "verbatim engine error expected, got: {submit_error}"
12185 );
12186 assert!(report.admitted.is_empty());
12187 }
12188
12189 #[tokio::test]
12190 async fn sync_report_surfaces_create_failure() {
12191 let (mut adv, _) = TrackingAdvertiser::new();
12192 adv.create_fails = true;
12193 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12194 assert!(!report.ok());
12195 assert!(
12196 report
12197 .create_error
12198 .as_deref()
12199 .unwrap_or_default()
12200 .contains("mock create refused"),
12201 "{report:?}"
12202 );
12203 assert!(report.submit_error.is_none(), "create failed before submit");
12204 }
12205
12206 #[tokio::test]
12207 async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
12208 let (mut adv, created) = TrackingAdvertiser::new();
12212 adv.existing_ship = vec![Advertisement {
12213 protocol: Protocol::Ship,
12214 identity_key: "02aa".to_string(),
12215 domain: "https://valid.example.com".to_string(),
12216 topic_or_service: "tm_test".to_string(),
12217 beef: None,
12218 output_index: None,
12219 }];
12220 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12221 assert!(report.ok(), "{report:?}");
12222 assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
12223 assert!(created.lock().unwrap().is_empty());
12224 assert!(
12225 report.effective(),
12226 "converged-noop is the one zero-admit shape that IS success"
12227 );
12228 }
12229
12230 #[tokio::test]
12231 async fn sync_report_zero_admit_is_ok_but_not_effective() {
12232 let (adv, _) = TrackingAdvertiser::new();
12238 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12239 managers.insert(
12240 "tm_test".to_string(),
12241 Box::new(MockTopicManager::admitting(vec![])), );
12243 let engine = Engine::new(
12244 managers,
12245 HashMap::new(),
12246 Box::new(MemoryStorage::new()),
12247 Some(Box::new(adv)),
12248 EngineConfig {
12249 hosting_url: Some("https://valid.example.com".to_string()),
12250 suppress_default_sync_advertisements: true,
12251 ..Default::default()
12252 },
12253 );
12254 let report = engine.sync_advertisements().await.unwrap();
12255 assert!(report.ok(), "no ERROR occurred: {report:?}");
12256 assert_eq!(report.to_create, 1);
12257 assert_eq!(report.admitted_total(), 0, "{report:?}");
12258 assert!(
12259 !report.effective(),
12260 "zero admitted on a non-empty create must not read as success: {report:?}"
12261 );
12262 }
12263
12264 #[tokio::test]
12265 async fn sync_report_partial_admit_is_not_effective() {
12266 let (mut adv, _) = TrackingAdvertiser::new();
12272 adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
12273 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12274 managers.insert(
12275 "tm_test".to_string(),
12276 Box::new(MockTopicManager::admitting(vec![0])),
12277 );
12278 managers.insert(
12279 "tm_refuse".to_string(),
12280 Box::new(MockTopicManager::admitting(vec![])), );
12282 let engine = Engine::new(
12283 managers,
12284 HashMap::new(),
12285 Box::new(MemoryStorage::new()),
12286 Some(Box::new(adv)),
12287 EngineConfig {
12288 hosting_url: Some("https://valid.example.com".to_string()),
12289 suppress_default_sync_advertisements: true,
12290 ..Default::default()
12291 },
12292 );
12293 let report = engine.sync_advertisements().await.unwrap();
12294 assert!(report.ok(), "no ERROR occurred: {report:?}");
12295 assert_eq!(report.to_create, 2, "{report:?}");
12296 assert_eq!(report.admitted_total(), 1, "{report:?}");
12297 assert!(
12298 !report.effective(),
12299 "a partial admit must not read as success: {report:?}"
12300 );
12301 }
12302
12303 #[tokio::test]
12304 async fn sync_report_lookup_failure_refuses_creation() {
12305 let (mut adv, created) = TrackingAdvertiser::new();
12309 adv.find_fails = true;
12310 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12311 assert!(!report.ok(), "{report:?}");
12312 assert!(!report.effective());
12313 assert!(
12314 report
12315 .lookup_error
12316 .as_deref()
12317 .unwrap_or_default()
12318 .contains("mock lookup refused"),
12319 "{report:?}"
12320 );
12321 assert_eq!(report.to_create, 0, "creation refused, not attempted");
12322 assert!(
12323 created.lock().unwrap().is_empty(),
12324 "no create call on a blind read"
12325 );
12326 assert!(report.submit_error.is_none());
12327 }
12328
12329 #[tokio::test]
12330 async fn sync_report_counts_skipped_revocations() {
12331 let (mut adv, _) = TrackingAdvertiser::new();
12334 adv.revoke_empty = true;
12335 adv.existing_ship = vec![
12336 Advertisement {
12337 protocol: Protocol::Ship,
12338 identity_key: "02aa".to_string(),
12339 domain: "https://valid.example.com".to_string(),
12340 topic_or_service: "tm_test".to_string(),
12341 beef: None,
12342 output_index: None,
12343 },
12344 Advertisement {
12345 protocol: Protocol::Ship,
12346 identity_key: "02aa".to_string(),
12347 domain: "https://valid.example.com".to_string(),
12348 topic_or_service: "tm_stale".to_string(), beef: None,
12350 output_index: None,
12351 },
12352 ];
12353 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12354 assert!(report.ok(), "{report:?}");
12355 assert_eq!(report.to_revoke, 1, "tm_stale is stale");
12356 assert_eq!(
12357 report.revoke_skipped, 1,
12358 "the v1 no-op must be visible, not silent: {report:?}"
12359 );
12360 assert!(report.revoke_error.is_none());
12361 assert!(report.revoke_submit_error.is_none());
12362 }
12363
12364 use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
12367 use std::cell::Cell;
12368
12369 const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12371
12372 fn proofless_beef() -> (Vec<u8>, String) {
12374 use bsv_rs::transaction::{Beef, Transaction};
12375 let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
12376 let txid = tx.id();
12377 let mut beef = Beef::new();
12378 beef.merge_transaction(tx);
12379 (beef.to_binary(), txid)
12380 }
12381
12382 fn subject_not_last_beef() -> (Vec<u8>, String, String) {
12392 use bsv_rs::transaction::Beef;
12393 let parsed = Beef::from_binary(&test_beef()).unwrap();
12394 let parent = parsed.txs[0].tx().unwrap().clone();
12395 let child = parsed.txs[1].tx().unwrap().clone();
12396 let (parent_id, child_id) = (parent.id(), child.id());
12397 assert_ne!(parent_id, child_id);
12398
12399 let mut beef = Beef::new();
12405 beef.merge_transaction(child); beef.merge_transaction(parent);
12407 let mut w = bsv_rs::primitives::encoding::Writer::new();
12408 beef.to_writer(&mut w);
12409 let bytes = w.into_bytes();
12410
12411 let reread = Beef::from_binary(&bytes).unwrap();
12414 assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
12415 (bytes, child_id, parent_id)
12416 }
12417
12418 #[test]
12419 fn stitch_preserves_the_subject_when_it_is_not_last() {
12420 use bsv_rs::transaction::{Beef, MerklePath};
12421 let (stored, child_id, parent_id) = subject_not_last_beef();
12422 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
12423
12424 let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
12425 .expect("stitch must succeed on a beef that contains the txid");
12426
12427 let after = Beef::from_binary(&out).unwrap();
12428 assert!(
12429 after.find_txid(&child_id).is_some(),
12430 "the SUBJECT must survive its own proof-completion"
12431 );
12432 assert!(
12433 after.find_txid(&parent_id).is_some(),
12434 "the ancestry must survive too"
12435 );
12436 assert!(
12437 after.find_bump(&child_id).is_some(),
12438 "the stitched bump must prove the subject"
12439 );
12440 assert!(
12441 after.find_bump(&parent_id).is_none(),
12442 "the parent must NOT be claimed by the child's bump"
12443 );
12444 }
12445
12446 #[test]
12451 fn the_old_round_trip_demonstrably_dropped_the_subject() {
12452 use bsv_rs::transaction::{Beef, Transaction};
12453 let (stored, child_id, _parent_id) = subject_not_last_beef();
12454 let tx = Transaction::from_beef(&stored, None).unwrap();
12455 assert_ne!(
12456 tx.id(),
12457 child_id,
12458 "from_beef(None) picks txs.last(), not the subject — the trigger"
12459 );
12460 let rebuilt = tx.to_beef(true).unwrap();
12461 let reparsed = Beef::from_binary(&rebuilt).unwrap();
12462 assert!(
12463 reparsed.find_txid(&child_id).is_none(),
12464 "the rebuild loses the subject — exactly the #284 artifact"
12465 );
12466 }
12467
12468 #[test]
12474 fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
12475 use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
12476 let parent = Transaction::from_hex(RAW_PARENT).unwrap();
12478 let parent_id = parent.id();
12479 let subject = child_of(&parent_id, 0);
12480 let subject_id = subject.id();
12481 let orphan = MerklePath::new_unchecked(
12484 965_771,
12485 vec![vec![
12486 MerklePathLeaf::new_txid(0, subject_id.clone()),
12487 MerklePathLeaf::new(1, "aa".repeat(32)),
12488 ]],
12489 )
12490 .unwrap();
12491 let mut beef = Beef::new();
12492 let bi = beef.merge_bump(orphan.clone());
12493 beef.merge_raw_tx(parent.to_binary(), None);
12494 beef.merge_raw_tx(subject.to_binary(), Some(bi));
12495 let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
12496 let stored = beef.to_binary();
12497 assert_eq!(
12498 Beef::from_binary(&stored)
12499 .unwrap()
12500 .find_bump(&subject_id)
12501 .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
12502 Some(orphan_root.clone())
12503 );
12504 let canonical = MerklePath::new_unchecked(
12506 965_771,
12507 vec![vec![
12508 MerklePathLeaf::new_txid(0, subject_id.clone()),
12509 MerklePathLeaf::new(1, "bb".repeat(32)),
12510 ]],
12511 )
12512 .unwrap();
12513 let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
12514 assert_ne!(
12515 canonical_root, orphan_root,
12516 "the reorg shape: same height, new root"
12517 );
12518 let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
12519 let after = Beef::from_binary(&out).unwrap();
12520 let own = after
12522 .find_txid(&subject_id)
12523 .and_then(BeefTx::bump_index)
12524 .and_then(|bi| after.bumps.get(bi))
12525 .unwrap();
12526 assert_eq!(
12527 own.compute_root(Some(&subject_id)).unwrap(),
12528 canonical_root,
12529 "the subject re-anchored to the new bump"
12530 );
12531 assert_eq!(
12533 after.bumps.len(),
12534 1,
12535 "the unreferenced orphan bump was dropped"
12536 );
12537 assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
12539 }
12540
12541 #[test]
12542 fn stitch_refuses_a_beef_that_lacks_the_txid() {
12543 use bsv_rs::transaction::MerklePath;
12544 let (stored, _c, _p) = subject_not_last_beef();
12545 let foreign = "aa".repeat(32);
12546 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
12547 assert!(
12548 Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
12549 "no txid in the beef -> no write, never garbage"
12550 );
12551 }
12552
12553 const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12555
12556 fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
12558 let mut sx = String::from("0100000001");
12559 let mut prev = hex::decode(source_txid).unwrap();
12560 prev.reverse();
12561 sx.push_str(&hex::encode(prev));
12562 sx.push_str(&hex::encode(vout.to_le_bytes()));
12563 sx.push_str("00ffffffff01");
12564 sx.push_str(&hex::encode(1000u64.to_le_bytes()));
12565 sx.push_str("015100000000");
12566 bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
12567 }
12568
12569 fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
12571 use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
12572 let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
12573 MerklePath::new_unchecked(height, vec![vec![leaf]])
12574 .unwrap()
12575 .to_hex()
12576 }
12577
12578 struct StubFetcher {
12581 proof: Option<String>,
12582 calls: Cell<u32>,
12583 }
12584
12585 #[async_trait(?Send)]
12586 impl AncestorFetcher for StubFetcher {
12587 async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
12588 self.calls.set(self.calls.get() + 1);
12589 Ok(FetchedAncestor {
12590 raw_tx: PROOFLESS_RAW_TX.to_string(),
12591 proof: self.proof.clone(),
12592 })
12593 }
12594
12595 async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
12601 true
12602 }
12603 }
12604
12605 async fn engine_with_proofless_output(
12608 fetcher: Option<std::rc::Rc<StubFetcher>>,
12609 ) -> (Engine, String) {
12610 let (beef, txid) = proofless_beef();
12611 let storage = MemoryStorage::new();
12612 storage
12613 .insert_output(&Output {
12614 txid: txid.clone(),
12615 output_index: 0,
12616 output_script: vec![0x76],
12617 satoshis: 1_000_000_000,
12618 topic: "Hello".to_string(),
12619 spent: false,
12620 outputs_consumed: vec![],
12621 consumed_by: vec![],
12622 beef: Some(beef),
12623 block_height: None,
12624 score: Some(1.0),
12625 })
12626 .await
12627 .unwrap();
12628
12629 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12630 managers.insert(
12631 "Hello".into(),
12632 Box::new(MockTopicManager::admitting(vec![0])),
12633 );
12634 let mut engine = Engine::new(
12635 managers,
12636 HashMap::new(),
12637 Box::new(storage),
12638 None,
12639 EngineConfig::default(),
12640 );
12641 if let Some(f) = fetcher {
12642 engine.set_ancestor_fetcher(f);
12643 }
12644 (engine, txid)
12645 }
12646
12647 #[tokio::test]
12648 async fn proofs_completion_uses_dedicated_courier() {
12649 let (_, txid) = proofless_beef();
12650 let shared = std::rc::Rc::new(StubFetcher {
12651 proof: None,
12652 calls: Cell::new(0),
12653 });
12654 let dedicated = StubFetcher {
12655 proof: Some(single_leaf_bump_hex(&txid, 850_000)),
12656 calls: Cell::new(0),
12657 };
12658 let (engine, _) = engine_with_proofless_output(Some(shared.clone())).await;
12659 assert_eq!(
12660 engine
12661 .complete_missing_proofs(1, 0)
12662 .await
12663 .unwrap()
12664 .completed,
12665 0
12666 );
12667 assert_eq!(
12668 engine
12669 .complete_missing_proofs_with(&dedicated, 1, 0)
12670 .await
12671 .unwrap()
12672 .completed,
12673 1
12674 );
12675 assert_eq!(shared.calls.get(), 1);
12676 assert_eq!(dedicated.calls.get(), 1);
12677 let output = engine
12678 .storage()
12679 .find_output(&txid, 0, Some("Hello"), None, true)
12680 .await
12681 .unwrap()
12682 .unwrap();
12683 assert!(
12684 crate::beef_limits::own_proof(output.beef.as_ref().unwrap(), &txid)
12685 .unwrap()
12686 .is_some()
12687 );
12688 }
12689
12690 #[tokio::test]
12691 async fn complete_missing_proofs_noop_without_fetcher() {
12692 let (engine, _txid) = engine_with_proofless_output(None).await;
12694 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12695 assert_eq!(summary, ProofCompletionSummary::default());
12696 }
12697
12698 #[tokio::test]
12699 async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
12700 let (_beef, txid) = proofless_beef();
12701 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12702 let fetcher = std::rc::Rc::new(StubFetcher {
12703 proof: Some(proof_hex),
12704 calls: Cell::new(0),
12705 });
12706 let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12707
12708 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12709 assert_eq!(summary.scanned, 1);
12710 assert_eq!(summary.proofless, 1);
12711 assert_eq!(
12712 summary.completed, 1,
12713 "the proofless BEEF should be completed"
12714 );
12715 assert_eq!(fetcher.calls.get(), 1);
12716
12717 let out = engine
12720 .storage()
12721 .find_output(&txid, 0, Some("Hello"), None, true)
12722 .await
12723 .unwrap()
12724 .unwrap();
12725 assert_eq!(out.block_height, Some(850_000));
12726 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
12727 assert!(
12728 beef.find_txid(&txid)
12729 .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
12730 "completed BEEF must prove the target tx"
12731 );
12732
12733 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
12736 assert_eq!(summary2.proofless, 0);
12737 assert_eq!(summary2.completed, 0);
12738 assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
12739 }
12740
12741 #[tokio::test]
12742 async fn complete_missing_proofs_skips_unconfirmed() {
12743 let fetcher = std::rc::Rc::new(StubFetcher {
12746 proof: None,
12747 calls: Cell::new(0),
12748 });
12749 let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12750 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12751 assert_eq!(summary.proofless, 1);
12752 assert_eq!(summary.completed, 0);
12753 assert_eq!(summary.still_unconfirmed, 1);
12754 assert_eq!(fetcher.calls.get(), 1);
12755 }
12756
12757 async fn engine_with_proofless_output_shared(
12762 fetcher: Option<std::rc::Rc<StubFetcher>>,
12763 ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
12764 let (beef, txid) = proofless_beef();
12765 let storage = std::rc::Rc::new(MemoryStorage::new());
12766 storage
12767 .insert_output(&Output {
12768 txid: txid.clone(),
12769 output_index: 0,
12770 output_script: vec![0x76],
12771 satoshis: 1_000_000_000,
12772 topic: "Hello".to_string(),
12773 spent: false,
12774 outputs_consumed: vec![],
12775 consumed_by: vec![],
12776 beef: Some(beef),
12777 block_height: None,
12778 score: Some(1.0),
12779 })
12780 .await
12781 .unwrap();
12782
12783 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12784 managers.insert(
12785 "Hello".into(),
12786 Box::new(MockTopicManager::admitting(vec![0])),
12787 );
12788 let mut engine = Engine::new(
12789 managers,
12790 HashMap::new(),
12791 Box::new(storage.clone()),
12792 None,
12793 EngineConfig::default(),
12794 );
12795 if let Some(f) = fetcher {
12796 engine.set_ancestor_fetcher(f);
12797 }
12798 (engine, txid, storage)
12799 }
12800
12801 #[tokio::test]
12802 async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
12803 let proof_hex = {
12807 let (_beef, txid) = proofless_beef();
12808 single_leaf_bump_hex(&txid, 850_000)
12809 };
12810 let fetcher = std::rc::Rc::new(StubFetcher {
12811 proof: Some(proof_hex),
12812 calls: Cell::new(0),
12813 });
12814 let (engine, _txid, storage) =
12815 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12816
12817 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12818 assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
12819 assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
12820
12821 storage.advance_clock(1800);
12826 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12827 assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
12828 assert_eq!(summary.completed, 1, "the backstop completes the proof");
12829 assert_eq!(fetcher.calls.get(), 1);
12830 }
12831
12832 #[tokio::test]
12833 async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
12834 let fetcher = std::rc::Rc::new(StubFetcher {
12840 proof: None,
12841 calls: Cell::new(0),
12842 });
12843 let (engine, txid, storage) =
12844 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12845
12846 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12849 engine
12850 .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
12851 .await
12852 .unwrap();
12853
12854 storage.advance_clock(1_000_000);
12857 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12858 assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
12859 assert_eq!(summary.completed, 0);
12860 assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
12861 }
12862
12863 #[tokio::test]
12864 async fn complete_missing_proofs_respects_limit() {
12865 let (beef1, txid1) = proofless_beef();
12867 let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
12869 let (beef2, txid2) = {
12870 use bsv_rs::transaction::{Beef, Transaction};
12871 let tx = Transaction::from_hex(raw2).unwrap();
12872 let id = tx.id();
12873 let mut b = Beef::new();
12874 b.merge_transaction(tx);
12875 (b.to_binary(), id)
12876 };
12877
12878 let storage = MemoryStorage::new();
12879 for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
12880 storage
12881 .insert_output(&Output {
12882 txid: txid.clone(),
12883 output_index: 0,
12884 output_script: vec![0x76],
12885 satoshis: 1000,
12886 topic: "Hello".to_string(),
12887 spent: false,
12888 outputs_consumed: vec![],
12889 consumed_by: vec![],
12890 beef: Some(beef),
12891 block_height: None,
12892 score: Some(1.0),
12893 })
12894 .await
12895 .unwrap();
12896 }
12897
12898 let fetcher = std::rc::Rc::new(StubFetcher {
12899 proof: None,
12900 calls: Cell::new(0),
12901 });
12902 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12903 managers.insert(
12904 "Hello".into(),
12905 Box::new(MockTopicManager::admitting(vec![0])),
12906 );
12907 let mut engine = Engine::new(
12908 managers,
12909 HashMap::new(),
12910 Box::new(storage),
12911 None,
12912 EngineConfig::default(),
12913 );
12914 engine.set_ancestor_fetcher(fetcher.clone());
12915
12916 let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
12917 assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
12918 }
12919
12920 #[tokio::test]
12921 async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
12922 use bsv_rs::transaction::{Beef, MerklePath, Transaction};
12931
12932 let storage = MemoryStorage::new();
12933
12934 let proven_raws = [
12939 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
12940 "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
12941 ];
12942 let mut proven_ids = Vec::new();
12943 for raw in proven_raws {
12944 let mut tx = Transaction::from_hex(raw).unwrap();
12945 let id = tx.id();
12946 tx.merkle_path =
12947 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
12948 let proven_beef = tx.to_beef(true).unwrap();
12951 assert_eq!(
12952 Beef::from_binary(&proven_beef)
12953 .unwrap()
12954 .find_txid(&id)
12955 .map(bsv_rs::transaction::BeefTx::has_proof),
12956 Some(true),
12957 "proven fixture row must carry its proof"
12958 );
12959 proven_ids.push(id.clone());
12960 storage
12961 .insert_output(&Output {
12962 txid: id,
12963 output_index: 0,
12964 output_script: vec![0x76],
12965 satoshis: 1000,
12966 topic: "Hello".to_string(),
12967 spent: false,
12968 outputs_consumed: vec![],
12969 consumed_by: vec![],
12970 beef: Some(proven_beef),
12971 block_height: Some(800_000),
12972 score: Some(1.0),
12973 })
12974 .await
12975 .unwrap();
12976 }
12977
12978 let fetcher = std::rc::Rc::new(StubFetcher {
12979 proof: None,
12980 calls: Cell::new(0),
12981 });
12982 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12983 managers.insert(
12984 "Hello".into(),
12985 Box::new(MockTopicManager::admitting(vec![0])),
12986 );
12987 let mut engine = Engine::new(
12988 managers,
12989 HashMap::new(),
12990 Box::new(storage),
12991 None,
12992 EngineConfig::default(),
12993 );
12994 engine.set_ancestor_fetcher(fetcher.clone());
12995
12996 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12999 assert_eq!(summary.scanned, proven_ids.len());
13000 assert_eq!(summary.proofless, 0);
13001 assert_eq!(summary.completed, 0);
13002 assert_eq!(
13003 summary.already_proven,
13004 proven_ids.len(),
13005 "every already-proven row is marked this pass"
13006 );
13007 assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
13008
13009 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
13012 assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
13013 assert_eq!(summary2.already_proven, 0);
13014 }
13015
13016 #[tokio::test]
13017 async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
13018 use bsv_rs::transaction::{MerklePath, Transaction};
13024
13025 let storage = MemoryStorage::new();
13026
13027 let proven_raws = [
13028 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
13029 ];
13030 for raw in proven_raws {
13031 let mut tx = Transaction::from_hex(raw).unwrap();
13032 let id = tx.id();
13033 tx.merkle_path =
13034 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
13035 let proven_beef = tx.to_beef(true).unwrap();
13036 storage
13037 .insert_output(&Output {
13038 txid: id,
13039 output_index: 0,
13040 output_script: vec![0x76],
13041 satoshis: 1000,
13042 topic: "Hello".to_string(),
13043 spent: false,
13044 outputs_consumed: vec![],
13045 consumed_by: vec![],
13046 beef: Some(proven_beef),
13047 block_height: Some(800_000),
13048 score: Some(1.0),
13049 })
13050 .await
13051 .unwrap();
13052 }
13053
13054 let (proofless, proofless_txid) = proofless_beef();
13056 storage
13057 .insert_output(&Output {
13058 txid: proofless_txid.clone(),
13059 output_index: 0,
13060 output_script: vec![0x76],
13061 satoshis: 1000,
13062 topic: "Hello".to_string(),
13063 spent: false,
13064 outputs_consumed: vec![],
13065 consumed_by: vec![],
13066 beef: Some(proofless),
13067 block_height: None,
13068 score: Some(1.0),
13069 })
13070 .await
13071 .unwrap();
13072
13073 let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
13074 let fetcher = std::rc::Rc::new(StubFetcher {
13075 proof: Some(proof_hex),
13076 calls: Cell::new(0),
13077 });
13078 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
13079 managers.insert(
13080 "Hello".into(),
13081 Box::new(MockTopicManager::admitting(vec![0])),
13082 );
13083 let mut engine = Engine::new(
13084 managers,
13085 HashMap::new(),
13086 Box::new(storage),
13087 None,
13088 EngineConfig::default(),
13089 );
13090 engine.set_ancestor_fetcher(fetcher.clone());
13091
13092 let mut completed_total = 0;
13095 for _ in 0..5 {
13096 let s = engine.complete_missing_proofs(1, 0).await.unwrap();
13097 completed_total += s.completed;
13098 if completed_total > 0 {
13099 break;
13100 }
13101 }
13102 assert_eq!(
13103 completed_total, 1,
13104 "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
13105 );
13106
13107 let out = engine
13108 .storage()
13109 .find_output(&proofless_txid, 0, Some("Hello"), None, true)
13110 .await
13111 .unwrap()
13112 .unwrap();
13113 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
13114 assert_eq!(
13115 beef.find_txid(&proofless_txid)
13116 .map(bsv_rs::transaction::BeefTx::has_proof),
13117 Some(true),
13118 "the old proofless tx now carries its proof"
13119 );
13120 }
13121
13122 use crate::storage::StorageError;
13130 use std::rc::Rc;
13131
13132 struct FaultingStorage {
13137 inner: MemoryStorage,
13138 fail_insert_output: Cell<bool>,
13139 fail_find_output: Cell<bool>,
13140 }
13141
13142 impl FaultingStorage {
13143 fn new() -> Rc<Self> {
13144 Rc::new(Self {
13145 inner: MemoryStorage::new(),
13146 fail_insert_output: Cell::new(false),
13147 fail_find_output: Cell::new(false),
13148 })
13149 }
13150 }
13151
13152 #[async_trait(?Send)]
13153 impl Storage for FaultingStorage {
13154 async fn insert_output_with_origin(
13155 &self,
13156 output: &Output,
13157 gasp: bool,
13158 ) -> Result<(), StorageError> {
13159 if self.fail_insert_output.get() {
13160 return Err(StorageError::Database(
13161 "D1_ERROR: storage overloaded".into(),
13162 ));
13163 }
13164 self.inner.insert_output_with_origin(output, gasp).await
13165 }
13166 async fn read_fault_memos(
13167 &self,
13168 host: &str,
13169 topic: &str,
13170 outpoints: Option<&[String]>,
13171 ) -> Result<crate::fault_memo::FaultMemoBatch, StorageError> {
13172 self.inner.read_fault_memos(host, topic, outpoints).await
13173 }
13174
13175 async fn put_deferred_graph(
13176 &self,
13177 record: &crate::gasp::DeferredGraph,
13178 ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
13179 self.inner.put_deferred_graph(record).await
13180 }
13181 async fn reserve_unanchorable_retry(
13182 &self,
13183 record: &crate::gasp::DeferredGraph,
13184 ) -> Result<bool, StorageError> {
13185 self.inner.reserve_unanchorable_retry(record).await
13186 }
13187 async fn find_deferred_graphs(
13188 &self,
13189 host: &str,
13190 topic: &str,
13191 ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
13192 self.inner.find_deferred_graphs(host, topic).await
13193 }
13194 async fn get_deferred_graph(
13195 &self,
13196 host: &str,
13197 topic: &str,
13198 outpoint: &str,
13199 ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
13200 self.inner.get_deferred_graph(host, topic, outpoint).await
13201 }
13202 async fn delete_deferred_graph(
13203 &self,
13204 host: &str,
13205 topic: &str,
13206 outpoint: &str,
13207 ) -> Result<(), StorageError> {
13208 self.inner
13209 .delete_deferred_graph(host, topic, outpoint)
13210 .await
13211 }
13212 async fn record_peer_sync_outcome(
13213 &self,
13214 host: &str,
13215 topic: &str,
13216 success: bool,
13217 ) -> Result<(), StorageError> {
13218 self.inner
13219 .record_peer_sync_outcome(host, topic, success)
13220 .await
13221 }
13222 async fn get_peer_sync_health(
13223 &self,
13224 host: &str,
13225 topic: &str,
13226 ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
13227 self.inner.get_peer_sync_health(host, topic).await
13228 }
13229 async fn record_peer_sync_yield(
13230 &self,
13231 host: &str,
13232 topic: &str,
13233 yielded: bool,
13234 ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
13235 self.inner
13236 .record_peer_sync_yield(host, topic, yielded)
13237 .await
13238 }
13239 async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
13240 self.insert_output_with_origin(output, false).await
13241 }
13242 async fn delete_output(
13243 &self,
13244 txid: &str,
13245 output_index: u32,
13246 topic: &str,
13247 ) -> Result<(), StorageError> {
13248 self.inner.delete_output(txid, output_index, topic).await
13249 }
13250 async fn mark_utxo_as_spent(
13251 &self,
13252 txid: &str,
13253 output_index: u32,
13254 topic: &str,
13255 ) -> Result<(), StorageError> {
13256 self.inner
13257 .mark_utxo_as_spent(txid, output_index, topic)
13258 .await
13259 }
13260 async fn update_consumed_by(
13261 &self,
13262 txid: &str,
13263 output_index: u32,
13264 topic: &str,
13265 consumed_by: &[Outpoint],
13266 ) -> Result<(), StorageError> {
13267 self.inner
13268 .update_consumed_by(txid, output_index, topic, consumed_by)
13269 .await
13270 }
13271 async fn update_transaction_beef(
13272 &self,
13273 txid: &str,
13274 beef: &[u8],
13275 ) -> Result<(), StorageError> {
13276 self.inner.update_transaction_beef(txid, beef).await
13277 }
13278 async fn insert_applied_transaction(
13279 &self,
13280 tx: &AppliedTransaction,
13281 ) -> Result<(), StorageError> {
13282 self.inner.insert_applied_transaction(tx).await
13283 }
13284 async fn does_applied_transaction_exist(
13285 &self,
13286 tx: &AppliedTransaction,
13287 ) -> Result<bool, StorageError> {
13288 self.inner.does_applied_transaction_exist(tx).await
13289 }
13290 async fn delete_applied_transaction(
13291 &self,
13292 tx: &AppliedTransaction,
13293 ) -> Result<(), StorageError> {
13294 self.inner.delete_applied_transaction(tx).await
13295 }
13296 async fn find_output(
13297 &self,
13298 txid: &str,
13299 output_index: u32,
13300 topic: Option<&str>,
13301 spent: Option<bool>,
13302 include_beef: bool,
13303 ) -> Result<Option<Output>, StorageError> {
13304 if self.fail_find_output.get() {
13305 return Err(StorageError::Database("D1_ERROR: read timed out".into()));
13306 }
13307 self.inner
13308 .find_output(txid, output_index, topic, spent, include_beef)
13309 .await
13310 }
13311 async fn find_outputs_for_transaction(
13312 &self,
13313 txid: &str,
13314 include_beef: bool,
13315 ) -> Result<Vec<Output>, StorageError> {
13316 self.inner
13317 .find_outputs_for_transaction(txid, include_beef)
13318 .await
13319 }
13320 async fn find_utxos_for_topic(
13321 &self,
13322 topic: &str,
13323 since: Option<f64>,
13324 limit: Option<u64>,
13325 include_beef: bool,
13326 ) -> Result<Vec<Output>, StorageError> {
13327 self.inner
13328 .find_utxos_for_topic(topic, since, limit, include_beef)
13329 .await
13330 }
13331 async fn update_last_interaction(
13332 &self,
13333 host: &str,
13334 topic: &str,
13335 since: u64,
13336 ) -> Result<(), StorageError> {
13337 self.inner.update_last_interaction(host, topic, since).await
13338 }
13339 async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
13340 self.inner.get_last_interaction(host, topic).await
13341 }
13342 }
13343
13344 fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
13345 let storage = FaultingStorage::new();
13346 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13347 managers.insert(
13348 "tm_test".to_string(),
13349 Box::new(MockTopicManager::admitting(admit_indices)),
13350 );
13351 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13352 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
13353 let engine = Engine::new(
13354 managers,
13355 lookup_services,
13356 Box::new(Rc::clone(&storage)),
13357 None,
13358 EngineConfig::default(),
13359 );
13360 (engine, storage)
13361 }
13362
13363 async fn applied(storage: &FaultingStorage) -> bool {
13364 storage
13365 .does_applied_transaction_exist(&AppliedTransaction {
13366 txid: TEST_TXID.to_string(),
13367 topic: "tm_test".to_string(),
13368 })
13369 .await
13370 .unwrap()
13371 }
13372
13373 #[tokio::test]
13376 async fn clean_submit_is_durable_and_records_applied() {
13377 let (engine, storage) = make_faulting_engine(vec![0]);
13378 let (steak, report) = engine
13379 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13380 .await
13381 .unwrap();
13382 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
13383 assert!(report.is_durable(), "{}", report.summary());
13384 assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
13385 assert!(applied(&storage).await);
13386 assert!(storage
13387 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13388 .await
13389 .unwrap()
13390 .is_some());
13391 }
13392
13393 #[tokio::test]
13398 async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
13399 let (engine, storage) = make_faulting_engine(vec![0]);
13400 storage.fail_insert_output.set(true);
13401 let (steak, report) = engine
13402 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13403 .await
13404 .unwrap();
13405 assert_eq!(
13406 steak["tm_test"].outputs_to_admit,
13407 vec![0],
13408 "the admission DECISION is unchanged by a write fault"
13409 );
13410 assert!(!report.is_durable());
13411 assert_eq!(report.faults.len(), 1, "{}", report.summary());
13412 assert_eq!(report.faults[0].topic, "tm_test");
13413 assert_eq!(report.faults[0].site, "insert_output");
13414 assert!(report.faults[0].error.contains("D1_ERROR"));
13415 assert!(report.applied_topics.is_empty());
13416 assert!(
13417 !applied(&storage).await,
13418 "a faulted topic must not be recorded as applied — the replay would be a dupe"
13419 );
13420 assert!(storage
13421 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13422 .await
13423 .unwrap()
13424 .is_none());
13425 }
13426
13427 #[tokio::test]
13431 async fn replay_after_a_fault_reapplies_and_records_applied() {
13432 let (engine, storage) = make_faulting_engine(vec![0]);
13433 storage.fail_insert_output.set(true);
13434 let (_, first) = engine
13435 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13436 .await
13437 .unwrap();
13438 assert!(!first.is_durable());
13439
13440 storage.fail_insert_output.set(false);
13441 let (steak, replay) = engine
13442 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13443 .await
13444 .unwrap();
13445 assert_eq!(
13446 steak["tm_test"].outputs_to_admit,
13447 vec![0],
13448 "re-validated, not deduped"
13449 );
13450 assert!(replay.is_durable(), "{}", replay.summary());
13451 assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
13452 assert!(applied(&storage).await);
13453 assert!(storage
13454 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13455 .await
13456 .unwrap()
13457 .is_some());
13458
13459 let (steak, third) = engine
13460 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13461 .await
13462 .unwrap();
13463 assert!(
13464 steak["tm_test"].outputs_to_admit.is_empty(),
13465 "now a genuine dupe: nothing admitted"
13466 );
13467 assert!(third.is_durable() && third.applied_topics.is_empty());
13468 }
13469
13470 #[tokio::test]
13476 async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
13477 let (engine, storage) = make_faulting_engine(vec![0]);
13478 let tx = Transaction::from_beef(&test_beef(), None).unwrap();
13481 let source_txid = tx.inputs[0].get_source_txid().unwrap();
13482 let source_vout = tx.inputs[0].source_output_index;
13483 storage
13484 .insert_output(&Output {
13485 txid: source_txid.clone(),
13486 output_index: source_vout,
13487 output_script: vec![0x51],
13488 satoshis: 1,
13489 topic: "tm_test".to_string(),
13490 spent: false,
13491 outputs_consumed: vec![],
13492 consumed_by: vec![],
13493 beef: None,
13494 block_height: None,
13495 score: Some(1.0),
13496 })
13497 .await
13498 .unwrap();
13499
13500 storage.fail_find_output.set(true);
13501 let (_, faulted) = engine
13502 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13503 .await
13504 .unwrap();
13505 assert!(!faulted.is_durable());
13506 assert!(
13507 faulted.faults.iter().any(|f| f.site == "find_output"),
13508 "{}",
13509 faulted.summary()
13510 );
13511 assert!(
13512 !applied(&storage).await,
13513 "unrecorded: the replay must re-read"
13514 );
13515 storage.fail_find_output.set(false);
13516 assert!(
13517 storage
13518 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13519 .await
13520 .unwrap()
13521 .is_some(),
13522 "the coin the faulted pass could not see is still there, untouched"
13523 );
13524
13525 let (_, replay) = engine
13526 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13527 .await
13528 .unwrap();
13529 assert!(replay.is_durable(), "{}", replay.summary());
13530 assert!(applied(&storage).await);
13531 assert!(
13534 storage
13535 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13536 .await
13537 .unwrap()
13538 .is_none(),
13539 "the healthy replay consumed the coin the faulted pass missed"
13540 );
13541 }
13542
13543 #[tokio::test]
13550 async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
13551 use std::cell::RefCell;
13552 use std::rc::Rc;
13553
13554 struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
13555 #[async_trait(?Send)]
13556 impl LookupServiceTrait for CountingLs {
13557 fn admission_mode(&self) -> AdmissionMode {
13558 self.1
13559 }
13560 fn spend_notification_mode(&self) -> SpendNotificationMode {
13561 SpendNotificationMode::None
13562 }
13563 async fn output_admitted_by_topic(
13564 &self,
13565 payload: &OutputAdmittedByTopic,
13566 ) -> Result<(), LookupServiceError> {
13567 let rec = match payload {
13568 OutputAdmittedByTopic::LockingScript {
13569 txid,
13570 output_index,
13571 topic,
13572 ..
13573 } => (txid.clone(), *output_index, topic.clone()),
13574 OutputAdmittedByTopic::WholeTx {
13575 atomic_beef,
13576 output_index,
13577 topic,
13578 ..
13579 } => {
13580 let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
13581 (
13582 beef.atomic_txid
13583 .clone()
13584 .expect("the WholeTx body NAMES its subject"),
13585 *output_index,
13586 topic.clone(),
13587 )
13588 }
13589 };
13590 self.0.borrow_mut().push(rec);
13591 Ok(())
13592 }
13593 async fn output_evicted(
13594 &self,
13595 _txid: &str,
13596 _output_index: u32,
13597 ) -> Result<(), LookupServiceError> {
13598 Ok(())
13599 }
13600 async fn lookup(
13601 &self,
13602 _question: &LookupQuestion,
13603 ) -> Result<LookupResult, LookupServiceError> {
13604 Ok(LookupResult::OutputList(Vec::new()))
13605 }
13606 async fn get_documentation(&self) -> String {
13607 "counting".to_string()
13608 }
13609 async fn get_metadata(&self) -> ServiceMetadata {
13610 ServiceMetadata {
13611 name: "counting-ls".to_string(),
13612 ..Default::default()
13613 }
13614 }
13615 }
13616
13617 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13618 managers.insert(
13619 "tm_test".to_string(),
13620 Box::new(MockTopicManager::admitting(vec![0])),
13621 );
13622 let seen = Rc::new(RefCell::new(Vec::new()));
13623 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13624 lookup_services.insert(
13625 "ls_whole".to_string(),
13626 Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
13627 );
13628 lookup_services.insert(
13629 "ls_script".to_string(),
13630 Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
13631 );
13632 let engine = Engine::new(
13633 managers,
13634 lookup_services,
13635 Box::new(MemoryStorage::new()),
13636 None,
13637 EngineConfig::default(),
13638 );
13639
13640 let tagged = test_tagged_beef(vec!["tm_test"]);
13641 engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13642 assert_eq!(seen.borrow().len(), 2, "one admit per service");
13643 let (txid, vout, _) = seen.borrow()[0].clone();
13644 assert_eq!(txid, TEST_TXID);
13645
13646 let (_, report) = engine
13648 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13649 .await
13650 .unwrap();
13651 assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
13652 assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
13653
13654 let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
13656 assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
13657 assert_eq!(r.vouts, vec![vout]);
13658 assert_eq!(seen.borrow().len(), 4);
13659 assert!(seen.borrow()[2..]
13660 .iter()
13661 .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
13662
13663 let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
13665 assert_eq!((r0.outputs, r0.notified), (0, 0));
13666 assert_eq!(seen.borrow().len(), 4);
13667
13668 let r2 = engine
13670 .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
13671 .await
13672 .unwrap();
13673 assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
13674 assert_eq!(seen.borrow().len(), 6);
13675 }
13676
13677 #[tokio::test]
13682 async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
13683 let engine = make_engine(vec![]);
13685 let tagged = test_tagged_beef(vec!["tm_test"]);
13686 let (steak, r1) = engine
13687 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13688 .await
13689 .unwrap();
13690 assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
13691 assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
13692 let (_, r2) = engine
13693 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13694 .await
13695 .unwrap();
13696 assert_eq!(
13697 r2.deduped_topics,
13698 vec!["tm_test".to_string()],
13699 "the phantom row dedups the re-submit"
13700 );
13701 assert!(engine
13702 .forget_phantom_applied(TEST_TXID, "tm_test")
13703 .await
13704 .unwrap());
13705 let (_, r3) = engine
13706 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13707 .await
13708 .unwrap();
13709 assert!(
13710 r3.deduped_topics.is_empty(),
13711 "judged again after the forget"
13712 );
13713 assert!(
13714 !engine
13715 .forget_phantom_applied(TEST_TXID, "tm_other")
13716 .await
13717 .unwrap(),
13718 "no row: nothing to forget"
13719 );
13720
13721 let real = make_engine(vec![0]);
13723 real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13724 assert!(!real
13725 .forget_phantom_applied(TEST_TXID, "tm_test")
13726 .await
13727 .unwrap());
13728 let (_, r4) = real
13729 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13730 .await
13731 .unwrap();
13732 assert_eq!(
13733 r4.deduped_topics,
13734 vec!["tm_test".to_string()],
13735 "a real admission still dedups"
13736 );
13737 }
13738
13739 const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
13745
13746 const WIDE_HEIGHT: u32 = 800_001;
13748
13749 fn wide_leaves(k: u32) -> Vec<String> {
13751 (0..1u32 << k)
13752 .map(|i| {
13753 let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
13754 id.reverse();
13755 hex::encode(id)
13756 })
13757 .collect()
13758 }
13759
13760 fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
13764 let k = txids.len().trailing_zeros() as usize;
13765 let mut levels = vec![Vec::new(); k];
13766 levels[0] = txids
13767 .iter()
13768 .enumerate()
13769 .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
13770 .collect();
13771 let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
13772 let root = bump.compute_root(None).unwrap();
13773 (bump, root)
13774 }
13775
13776 fn w1_bytes(k: u32) -> (Vec<u8>, String) {
13779 let (bump, root) = wide_bump(&wide_leaves(k));
13780 let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
13781 beef.extend_from_slice(&bump.to_binary());
13782 beef.push(0x00);
13783 (beef, root)
13784 }
13785
13786 fn walk_within_bound(
13790 bytes: Vec<u8>,
13791 root: String,
13792 verify_scripts: bool,
13793 subject: String,
13794 what: &str,
13795 ) -> (Result<(), String>, std::time::Duration) {
13796 let (tx, rx) = std::sync::mpsc::channel();
13797 std::thread::spawn(move || {
13798 let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
13799 tracker.add_root(WIDE_HEIGHT, root);
13800 let runtime = tokio::runtime::Builder::new_current_thread()
13801 .build()
13802 .unwrap();
13803 let started = std::time::Instant::now();
13804 let verdict = runtime.block_on(verify_spv_like_the_reference(
13805 Some(&tracker),
13806 verify_scripts,
13807 &bytes,
13808 &subject,
13809 DoorBudget::DEFAULT,
13810 ));
13811 let verdict = match verdict {
13812 Ok(None) => Ok(()),
13813 Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
13814 Err(e) => Err(e.to_string()),
13815 };
13816 let _ = tx.send((verdict, started.elapsed()));
13817 });
13818 rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
13819 panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
13820 })
13821 }
13822
13823 #[test]
13827 fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
13828 for k in [12u32, 13] {
13829 let (bytes, root) = w1_bytes(k);
13830 let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
13831 let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
13832 println!(
13833 "NL-6e W1 {what}: {verdict:?} in {:.3} s",
13834 took.as_secs_f64()
13835 );
13836 assert_eq!(verdict, Ok(()), "{what}");
13837 }
13838 }
13839
13840 fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
13845 use bsv_rs::script::{LockingScript, UnlockingScript};
13846 use bsv_rs::transaction::{TransactionInput, TransactionOutput};
13847 let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
13848 let spending = |txid: &str| {
13849 let mut input = TransactionInput::new(txid.to_string(), 0);
13850 input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
13851 input
13852 };
13853 let parents: Vec<Transaction> = (0..1u32 << k)
13854 .map(|i| {
13855 let mut tx = Transaction::new();
13856 let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
13858 input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
13859 tx.inputs.push(input);
13860 tx.outputs.push(TransactionOutput::new(1, op_true()));
13861 tx
13862 })
13863 .collect();
13864 let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
13865 let (bump, root) = wide_bump(&ids);
13866 let mut beef = Beef::new();
13867 let at = beef.merge_bump(bump);
13868 for parent in &parents {
13869 beef.merge_raw_tx(parent.to_binary(), Some(at));
13870 }
13871 let mut layer = ids;
13872 while layer.len() > 1 {
13873 layer = layer
13874 .chunks(2)
13875 .map(|pair| {
13876 let mut tx = Transaction::new();
13877 tx.inputs.push(spending(&pair[0]));
13878 tx.inputs.push(spending(&pair[1]));
13879 tx.outputs.push(TransactionOutput::new(0, op_true()));
13880 beef.merge_raw_tx(tx.to_binary(), None);
13881 tx.id()
13882 })
13883 .collect();
13884 }
13885 (beef.to_binary(), root, layer.remove(0))
13886 }
13887
13888 #[test]
13889 fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
13890 for k in [12u32, 13] {
13891 let (bytes, root, subject) = proven_fan_in(k);
13892 let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
13893 let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
13894 println!(
13895 "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
13896 took.as_secs_f64()
13897 );
13898 assert_eq!(verdict, Ok(()), "{what}");
13899 }
13900 }
13901 include!("minutes_tests.rs");
13902 include!("memo_tests.rs");
13903 include!("absence_tests.rs");
13904 include!("route_fault_tests.rs");
13905 include!("batch_tests.rs");
13906}