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 page = match &self.finalize_submit_budget {
5381 Some((sleep, budget_ms)) => crate::page_batch::PageBuffer::default()
5382 .with_bound(sleep.clone(), *budget_ms),
5383 None => crate::page_batch::PageBuffer::default(),
5384 };
5385 let mut sync = GASPSync::new(
5386 Box::new(gasp_storage),
5387 gasp_remote,
5388 last_interaction,
5389 &log_prefix,
5390 true, )
5392 .with_ancestor_fetcher(sync_fetcher.clone())
5393 .with_expired_outpoints(std::rc::Rc::clone(&expired))
5394 .with_batch_memo(run.single_only(peer_url));
5395 if let Some(control) = control {
5396 let control = control.clone();
5397 sync = sync.with_page_slice(std::rc::Rc::new(move || {
5398 control.stop_reason().is_some()
5399 }));
5400 }
5401 if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5404 let (sleep, budget_ms) =
5405 (sleep.clone(), (*budget_ms).min(crate::meter::ROOT_MAX_MS));
5406 let half_sleep = sleep.clone();
5407 let share_sleep = sleep.clone();
5408 sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5409 max_calls: *max_calls,
5410 max_bytes_fetched: self.graph_budget_limbs.0,
5411 max_nodes: self.graph_budget_limbs.1,
5412 deadline: Box::new(move || sleep(budget_ms)),
5413 half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5414 });
5415 if let Some((_, peer_ms)) = &self.peer_sync_budget {
5418 let share_ms = crate::gasp::resume_share_ms(*peer_ms, budget_ms);
5419 sync = sync.with_resume_share(Box::new(move || share_sleep(share_ms)));
5420 }
5421 }
5422 if self.peer_sync_budget.is_some() {
5427 sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5428 engine: self,
5429 sink: sink.clone(),
5430 peer_url,
5431 topic,
5432 submitted: &peer_finalized,
5433 }));
5434 }
5435
5436 let sync_outcome = match &self.peer_sync_budget {
5446 Some((sleep, budget_ms)) => {
5447 let budget_ms =
5448 control.map_or(*budget_ms, |c| (*budget_ms).min(c.remaining_ms()));
5449 let mut deadline = sleep(budget_ms);
5450 let deadline = std::future::poll_fn(|cx| {
5451 if control.is_some_and(|c| c.stop_reason().is_some())
5452 || std::future::Future::poll(deadline.as_mut(), cx).is_ready()
5453 {
5454 std::task::Poll::Ready(())
5455 } else {
5456 std::task::Poll::Pending
5457 }
5458 });
5459 crate::gasp::race_or_deadline_guarded(
5460 crate::page_batch::scope(
5461 page.clone(),
5462 sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5463 ),
5464 deadline,
5465 &self.finalize_gate,
5466 )
5467 .await
5468 }
5469 None => Some(
5470 crate::page_batch::scope(
5471 page.clone(),
5472 sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5473 )
5474 .await,
5475 ),
5476 };
5477 let page_lost = page.pending() > 0
5483 && {
5484 page.close();
5485 match page.flush(self.storage.as_ref()).await {
5486 Ok(()) => false,
5487 Err(e) => {
5488 warn!(
5489 "[GASP SYNC] the page's storage batch for {topic} from {peer_url} failed, nothing of it admitted: {e}"
5490 );
5491 page.discard();
5492 true
5493 }
5494 }
5495 };
5496 page.close();
5497
5498 let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5499 let route_faulted = matches!(
5502 sync_outcome,
5503 Some(Err(crate::gasp::GASPError::RouteFault { .. }))
5504 );
5505 pruned_inputs += sync.pruned_inputs();
5511 expired_refused += sync.expired_refused();
5512 roots_proven += sync.roots_proven();
5513 roots_unproven += sync.roots_unproven();
5514 discarded_graphs += sync.discarded_graphs();
5519 anchor_walk_held_graphs += sync.anchor_walk_held();
5520 let ran = !matches!(sync_outcome, Some(Err(_)));
5524 let mut advanced_cursor = false;
5525 match sync_outcome {
5526 None => {
5527 let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5528 let msg = format!(
5529 "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5530 );
5531 warn!("[GASP SYNC] {msg}");
5532 errors.push(msg);
5533
5534 let in_flight = u64::from(sync.graph_in_flight());
5545 deadline_dropped_graphs += in_flight;
5546 sync.defer_in_flight().await;
5549 let completed = match sync.page_floor() {
5550 Some(floor) if page_lost => sync.completed_cursor().min(floor),
5551 _ => sync.completed_cursor(),
5552 };
5553 if completed > last_interaction {
5554 match self
5555 .storage
5556 .update_last_interaction(peer_url, topic, completed)
5557 .await
5558 {
5559 Ok(()) => cursor_moves.push(CursorMove {
5560 peer: peer_url.clone(),
5561 from: last_interaction,
5562 to: completed,
5563 }),
5564 Err(e) => warn!(
5565 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5566 ),
5567 }
5568 }
5569 advanced_cursor = completed > last_interaction;
5589 outcome_success = peer_finalized.get() > 0
5590 || (completed > last_interaction && sync.cursor_yielded())
5591 || sync.deferral_stats().progressed > 0
5592 || sync.did_work();
5593 warn!(
5594 "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5595 peer_finalized.get(),
5596 completed.max(last_interaction)
5597 );
5598 }
5599 Some(Ok(())) => {
5600 let landed = self
5604 .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5605 .await;
5606 let stats = sync.deferral_stats();
5617 if stats.stalled > 0
5618 && stats.progressed == 0
5619 && peer_finalized.get() == 0
5620 && !sync.did_work()
5621 && !(landed
5622 && sync.last_interaction > last_interaction
5623 && sync.cursor_yielded())
5624 {
5625 outcome_success = false;
5626 let msg = format!(
5627 "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5628 stats.stalled
5629 );
5630 warn!("[GASP SYNC] {msg}");
5631 errors.push(msg);
5632 }
5633
5634 advanced_cursor = landed && sync.last_interaction > last_interaction;
5653 if !landed {
5654 warn!(
5655 "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5656 );
5657 } else if sync.last_interaction > last_interaction {
5658 match self
5659 .storage
5660 .update_last_interaction(peer_url, topic, sync.last_interaction)
5661 .await
5662 {
5663 Ok(()) => cursor_moves.push(CursorMove {
5664 peer: peer_url.clone(),
5665 from: last_interaction,
5666 to: sync.last_interaction,
5667 }),
5668 Err(e) => warn!(
5669 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5670 ),
5671 }
5672 }
5673 info!(
5674 "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5675 sync.last_interaction
5676 );
5677 }
5678 Some(Err(e)) => {
5679 let msg = format!("{peer_url}: {e}");
5680 warn!("[GASP SYNC] Sync failed: {msg}");
5681 errors.push(msg);
5682 if let crate::gasp::GASPError::RouteFault { class, .. } = e {
5683 route_faults.push(crate::route_fault::RouteFaultSeen {
5684 peer: peer_url.clone(),
5685 class,
5686 });
5687 }
5688 }
5689 }
5690 let yielded = peer_finalized.get() > 0
5707 || (advanced_cursor && sync.cursor_yielded())
5708 || sync.expired_refused() > 0;
5709 if yielded || sync.did_work() {
5710 outcome_success = true;
5711 }
5712 let route_excused = route_faulted && !outcome_success;
5720 if route_excused {
5721 info!(
5722 "[GASP SYNC] {peer_url} for {topic}: {} before the peer was reached; nothing counted against the peer (#57)",
5723 route_faults
5724 .last()
5725 .map_or(String::new(), crate::route_fault::RouteFaultSeen::label)
5726 );
5727 }
5728 if ran
5739 && self.graph_budget.is_some()
5740 && (yielded || sync.graphs_attempted() > 0)
5741 {
5742 match self
5743 .storage
5744 .record_peer_sync_yield(&health_key, topic, yielded)
5745 .await
5746 {
5747 Ok(streak)
5748 if !yielded
5749 && crate::gasp::yieldless_sync_failed(&streak)
5750 && outcome_success =>
5751 {
5752 outcome_success = false;
5753 let msg = format!(
5754 "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5755 streak.yieldless_syncs,
5756 streak.secs_since_first.unwrap_or(0),
5757 crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5758 crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5759 );
5760 warn!("[GASP SYNC] {msg}");
5761 errors.push(msg);
5762 }
5763 Ok(_) => {}
5764 Err(e) => warn!(
5765 "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5766 ),
5767 }
5768 }
5769 let quiet = outcome_success
5772 && !yielded
5773 && !sync.did_work()
5774 && sync.faulted_roots().is_empty()
5775 && sync.deferral_stats().stalled == 0;
5776 let quiet_syncs = if quiet && !filter.explicit() {
5777 if reset_cadence {
5778 1
5779 } else {
5780 cadence.quiet_syncs.saturating_add(1)
5781 }
5782 } else {
5783 0
5784 };
5785 let next = crate::sync_load::PeerCadence {
5786 quiet_syncs,
5787 retry_after_secs: crate::sync_load::quiet_retry_secs(quiet_syncs),
5788 advertisement,
5789 };
5790 if !route_excused {
5791 if let Err(e) = self
5792 .storage
5793 .set_peer_cadence(&health_key, topic, &next)
5794 .await
5795 {
5796 warn!(
5797 "[GASP SYNC] failed to record cadence for {peer_url}/{topic}: {e}"
5798 );
5799 }
5800 }
5801 if !advanced_cursor {
5802 if let Some(outpoint) = sync.cursor_held_by() {
5803 cursor_held_by.push(outpoint);
5804 }
5805 }
5806 finalized_graphs += peer_finalized.get();
5807 let deferral = sync.deferral_stats();
5808 root_bounds.extend(deferral.root_bounds.iter().cloned().map(|mut bound| {
5809 bound.peer.clone_from(peer_url);
5810 bound
5811 }));
5812 deferred_graphs += deferral.deferred;
5813 resumed_graphs += deferral.resumed;
5814 converged_graphs += deferral.converged;
5815 stalled_graphs += deferral.stalled;
5816 held_back_graphs += deferral.held_back;
5817 backed_off_graphs += deferral.backed_off;
5818 waiting_graphs += deferral.waiting;
5819 idle_counted += deferral.idle_counted;
5820 dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5821 peer: peer_url.clone(),
5822 outpoint: d.outpoint,
5823 reason: d.reason.as_str().to_string(),
5824 }));
5825 dead_graphs += sync.dead_recorded();
5827 expired_swept += sync.expired_swept();
5828 memo_folded += sync.memo_folded();
5829 unanchorable_graphs += sync.unanchorable_recorded();
5830 unanchorable_cleared += sync.unanchorable_cleared();
5831 absent_struck += sync.absent_struck();
5832 strikes_refused += sync.strikes_refused();
5834 faulted_roots.extend(sync.faulted_roots().into_iter().map(|mut f| {
5835 f.peer.clone_from(peer_url);
5836 f
5837 }));
5838
5839 if route_excused {
5859 } else if outcome_success
5861 && !yielded
5862 && self.graph_budget.is_some()
5863 && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5864 {
5865 info!(
5866 "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5867 health.consecutive_failures
5868 );
5869 } else if let Err(e) = self
5870 .storage
5871 .record_peer_sync_outcome(&health_key, topic, outcome_success)
5872 .await
5873 {
5874 warn!(
5875 "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5876 );
5877 }
5878 let cursor = self.storage.get_last_interaction(peer_url, topic).await?;
5879 let cost = crate::meter::snapshot().since(peer_cost_before);
5880 peer_results.insert(
5881 peer_url.clone(),
5882 crate::backfill::PeerSyncResult {
5883 slices: u64::from(sync.page_advanced() || cost.outputs_walked > 0),
5884 pages: sync.pages(),
5885 reached_tip: sync.reached_tip() && cursor >= sync.completed_cursor(),
5886 cursor,
5887 cost,
5888 },
5889 );
5890 }
5891 }
5892
5893 topics_synced.insert(
5894 topic.clone(),
5895 TopicSyncResult {
5896 cost: crate::meter::snapshot().since(cost_before),
5897 root_bounds,
5898 peers_skipped,
5899 peers_filtered,
5900 roots_proven,
5901 roots_unproven,
5902 cursor_held_by,
5903 expired_refused,
5904 peers,
5905 sync_type,
5906 errors,
5907 pruned_inputs,
5908 discarded_graphs,
5909 anchor_walk_held_graphs,
5910 finalized_graphs,
5911 deadline_dropped_graphs,
5912 cursor_moves,
5913 deferred_graphs,
5914 resumed_graphs,
5915 converged_graphs,
5916 dropped_graphs,
5917 stalled_graphs,
5918 held_back_graphs,
5919 backed_off_graphs,
5920 waiting_graphs,
5921 idle_counted,
5922 peer_results,
5923 dead_graphs,
5924 expired_swept,
5925 memo_folded,
5926 unanchorable_graphs,
5927 unanchorable_cleared,
5928 absent_struck,
5929 route_faults,
5930 strikes_refused,
5931 faulted_roots,
5932 },
5933 );
5934 }
5935
5936 info!(
5937 "[GASP SYNC] Peer discovery complete for {} topic(s)",
5938 topics_synced.len()
5939 );
5940
5941 let expired: u64 = topics_synced.values().map(|r| r.expired_refused).sum();
5942 if expired > 0 {
5943 info!("[GASP SYNC] gaspExpiredRefused={expired}");
5944 }
5945 let unanchorable: u64 = topics_synced.values().map(|r| r.unanchorable_graphs).sum();
5946 if unanchorable > 0 {
5947 info!("[GASP SYNC] gaspUnanchorable={unanchorable}");
5948 }
5949 let absent: u64 = topics_synced.values().map(|r| r.absent_struck).sum();
5950 if absent > 0 {
5951 info!("[GASP SYNC] gaspAbsentStruck={absent}");
5952 }
5953 let routes: usize = topics_synced.values().map(|r| r.route_faults.len()).sum();
5954 if routes > 0 {
5955 info!("[GASP SYNC] gaspRouteFaults={routes}");
5956 }
5957 let refused: u64 = topics_synced.values().map(|r| r.strikes_refused).sum();
5958 if refused > 0 {
5959 info!("[GASP SYNC] gaspStrikesRefused={refused}");
5960 }
5961 Ok(GASPSyncResult {
5962 cost: crate::meter::snapshot(),
5963 topics_synced,
5964 filter: filter.clone(),
5965 topics_filtered,
5966 })
5967 }
5968
5969 async fn submit_finalized_graphs(
5993 &self,
5994 sink: &crate::gasp_overlay::FinalizedGraphSink,
5995 peer_url: &str,
5996 topic: &str,
5997 submitted: &std::cell::Cell<u64>,
5998 ) -> bool {
5999 let finalized: Vec<_> = sink
6000 .lock()
6001 .unwrap_or_else(std::sync::PoisonError::into_inner)
6002 .drain(..)
6003 .collect();
6004
6005 let mut all_landed = true;
6006 for graph in &finalized {
6007 for (position, beef_bytes) in graph.beefs.iter().enumerate() {
6008 self.finalize_gate.stop_if_due().await;
6009 if position == 0 {
6010 submitted.set(submitted.get() + 1);
6011 }
6012 let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
6013 let outcome = {
6023 let _writing = self.finalize_gate.write_section();
6024 let bound = self
6025 .finalize_submit_budget
6026 .as_ref()
6027 .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
6028 self.submit_bounded(
6029 &tagged,
6030 SubmitMode::HistoricalTxNoSpv,
6031 bound.as_ref(),
6032 true,
6033 WalkBreachPolicy::NotNow,
6034 )
6035 .await
6036 };
6037 let not_landed = match outcome {
6038 Ok((_, report)) => {
6039 let landed = report.is_durable()
6040 && (report.applied_topics.contains(&graph.topic)
6041 || report.deduped_topics.contains(&graph.topic));
6042 (!landed).then(|| {
6043 if report.is_durable() {
6044 "the topic manager failed on it".to_string()
6045 } else {
6046 format!("not durable: {}", report.summary())
6047 }
6048 })
6049 }
6050 Err(e) => Some(e.to_string()),
6051 };
6052 if let Some(why) = not_landed {
6053 warn!(
6054 "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
6055 graph.topic
6056 );
6057 all_landed = false;
6058 break;
6059 }
6060 }
6061 }
6062
6063 if !finalized.is_empty() {
6064 info!(
6065 "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
6066 finalized.len()
6067 );
6068 }
6069 all_landed
6070 }
6071
6072 pub async fn rerequest_held_proofs(
6078 &self,
6079 candidates: &[crate::proof_window::HeldOutput],
6080 progress: &std::cell::Cell<crate::proof_window::PeerProofSummary>,
6081 ) {
6082 use crate::proof_window::{PEER_PROOF_REQUEST_MS, PEER_PROOF_ROW_LIMIT};
6083 let Some(factory) = &self.gasp_remote_factory else {
6084 return;
6085 };
6086 let Some(tracker) = self.chain_tracker.as_deref() else {
6087 return;
6088 };
6089 let mut lookup_hosts = None;
6090 let mut topic_peers = HashMap::new();
6091 let mut landed = HashSet::new();
6092 for candidate in candidates.iter().take(PEER_PROOF_ROW_LIMIT) {
6093 if !self.managers.contains_key(&candidate.topic) {
6094 continue;
6095 }
6096 if let std::collections::hash_map::Entry::Vacant(entry) =
6097 topic_peers.entry(candidate.topic.clone())
6098 {
6099 let peers = match self.config.sync_configuration.get(&candidate.topic) {
6100 Some(SyncTarget::Ship) => {
6101 self.discover_ship_peers(&candidate.topic, &mut lookup_hosts)
6102 .await
6103 }
6104 Some(SyncTarget::Peers(peers)) => self.ship_peers_excluding_self(peers.clone()),
6105 _ => Vec::new(),
6106 };
6107 entry.insert(peers);
6108 }
6109 for peer in &topic_peers[&candidate.topic] {
6110 let remote = factory.create_remote(peer, &candidate.topic);
6111 let graph_id = format!("{}.{}", candidate.txid, candidate.output_index);
6112 let mut summary = progress.get();
6113 summary.requests += 1;
6114 progress.set(summary);
6115 let ask =
6116 remote.request_node(&graph_id, &candidate.txid, candidate.output_index, false);
6117 let response = if let Some((sleep, _)) = &self.peer_sync_budget {
6118 crate::gasp::race_or_deadline(ask, sleep(PEER_PROOF_REQUEST_MS)).await
6119 } else {
6120 Some(ask.await)
6121 };
6122 let Some(Ok(node)) = response else {
6123 let mut summary = progress.get();
6124 summary.faults += 1;
6125 progress.set(summary);
6126 continue;
6127 };
6128 if landed.contains(&candidate.txid) {
6129 continue;
6130 }
6131 let Some(proof_hex) = node.proof else {
6132 continue;
6133 };
6134 let Ok(mut proof) = beef_limits::merkle_path_from_hex(
6135 &proof_hex,
6136 beef_limits::COURIER_PROOF_MAX_BYTES,
6137 ) else {
6138 let mut summary = progress.get();
6139 summary.refused += 1;
6140 progress.set(summary);
6141 continue;
6142 };
6143 let valid = match proof.compute_root(Some(&candidate.txid)) {
6144 Ok(root) => tracker
6145 .is_valid_root_for_height(&root, proof.block_height)
6146 .await
6147 .unwrap_or(false),
6148 Err(_) => false,
6149 };
6150 if !valid {
6151 let mut summary = progress.get();
6152 summary.refused += 1;
6153 progress.set(summary);
6154 continue;
6155 }
6156 for leaf in &mut proof.path[0] {
6159 if leaf.hash.as_deref() == Some(&candidate.txid) {
6160 leaf.txid = true;
6161 }
6162 }
6163 match self.land_peer_proof(candidate, &proof).await {
6164 Ok(()) => {
6165 landed.insert(candidate.txid.clone());
6166 let mut summary = progress.get();
6167 summary.landed += 1;
6168 progress.set(summary);
6169 }
6170 Err(error) => {
6171 warn!(%error, "[proof window] verified proof could not be stored");
6172 let mut summary = progress.get();
6173 summary.faults += 1;
6174 progress.set(summary);
6175 }
6176 }
6177 }
6178 }
6179 }
6180
6181 async fn land_peer_proof(
6182 &self,
6183 candidate: &crate::proof_window::HeldOutput,
6184 proof: &bsv_rs::transaction::MerklePath,
6185 ) -> Result<(), EngineError> {
6186 let output = self
6187 .storage
6188 .find_output(
6189 &candidate.txid,
6190 candidate.output_index,
6191 Some(&candidate.topic),
6192 None,
6193 true,
6194 )
6195 .await
6196 .map_err(|e| EngineError::StorageError(e.to_string()))?
6197 .ok_or(EngineError::NodeNotFound)?;
6198 let beef = output
6199 .beef
6200 .ok_or_else(|| EngineError::BeefParseError("missing stored BEEF".into()))?;
6201 let stitched = Self::stitch_proof_into_stored_beef(&beef, &candidate.txid, proof)
6202 .ok_or_else(|| {
6203 EngineError::BeefParseError("proof does not stitch into stored subject".into())
6204 })?;
6205 self.storage
6206 .update_transaction_beef(&candidate.txid, &stitched)
6207 .await
6208 .map_err(|e| EngineError::StorageError(e.to_string()))?;
6209 self.storage
6210 .mark_transaction_proven_at(&candidate.txid, Some(u64::from(proof.block_height)))
6211 .await
6212 .map_err(|e| EngineError::StorageError(e.to_string()))
6213 }
6214
6215 async fn peer_advertisement_versions(&self, topic: &str) -> HashMap<String, String> {
6218 let mut versions: HashMap<String, Vec<String>> = HashMap::new();
6219 for (ad_topic, protocol, scope) in [
6220 ("tm_slap", &b"SLAP"[..], None),
6221 ("tm_ship", &b"SHIP"[..], Some(topic)),
6222 ] {
6223 let rows = self
6224 .storage
6225 .find_utxos_for_topic(ad_topic, None, None, false)
6226 .await
6227 .unwrap_or_default();
6228 for row in rows {
6229 if let Some(domain) =
6230 parse_advertisement_domain_from_script(&row.output_script, protocol, scope)
6231 {
6232 versions
6233 .entry(crate::gasp::peer_origin(&domain))
6234 .or_default()
6235 .push(format!("{}:{}", row.txid, row.output_index));
6236 }
6237 }
6238 }
6239 versions
6240 .into_iter()
6241 .map(|(peer, mut keys)| {
6242 keys.sort();
6243 (peer, keys.join(","))
6244 })
6245 .collect()
6246 }
6247
6248 async fn discover_ship_peers(
6249 &self,
6250 topic: &str,
6251 lookup_hosts: &mut Option<Vec<String>>,
6252 ) -> Vec<String> {
6253 let mut domains = self.local_ship_peers(topic).await;
6254 if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
6255 domains.extend(bootstrap.iter().cloned());
6256 }
6257 if let Some(client) = &self.ship_lookup_client {
6258 if lookup_hosts.is_none() {
6259 *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
6260 }
6261 let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
6262 for host in lookup_hosts.as_ref().into_iter().flatten() {
6263 crate::meter::peer_request();
6264 match client.lookup(host, &question).await {
6265 Ok(outputs) => {
6266 domains.extend(outputs.iter().filter_map(|output| {
6267 parse_lookup_advertisement(output, b"SHIP", topic)
6268 }));
6269 }
6270 Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
6271 }
6272 }
6273 }
6274 self.ship_peers_excluding_self(domains)
6275 }
6276
6277 async fn discover_ship_lookup_hosts(
6278 &self,
6279 client: &dyn crate::ship_lookup::ShipLookupClient,
6280 ) -> Vec<String> {
6281 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
6282 let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
6283 let tracker_origins: HashSet<_> = trackers
6284 .iter()
6285 .map(|host| crate::gasp::peer_origin(host))
6286 .collect();
6287 let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
6288 let mut advertised = Vec::new();
6289 for tracker in &trackers {
6290 crate::meter::peer_request();
6291 match client.lookup(tracker, &question).await {
6292 Ok(outputs) => {
6293 advertised.extend(outputs.iter().filter_map(|output| {
6294 parse_lookup_advertisement(output, b"SLAP", "ls_ship")
6295 }));
6296 }
6297 Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
6298 }
6299 }
6300 let providers = self
6301 .ship_peers_excluding_self(advertised)
6302 .into_iter()
6303 .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
6304 .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
6305 trackers.into_iter().chain(providers).collect()
6306 }
6307
6308 fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
6309 crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
6310 self.config.hosting_url.as_ref().is_none_or(|our_url| {
6311 crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
6312 })
6313 }))
6314 }
6315
6316 async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
6318 let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
6319 warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
6320 return Vec::new();
6321 };
6322
6323 let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
6324
6325 let refs = match ship_ls.lookup(&question).await {
6326 Ok(LookupResult::OutputList(refs)) => refs,
6327 Ok(LookupResult::Answer(_)) => {
6328 error!(
6329 "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
6330 LookupAnswer; expected OutputList. Skipping."
6331 );
6332 return Vec::new();
6333 }
6334 Err(e) => {
6335 error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
6336 return Vec::new();
6337 }
6338 };
6339
6340 let mut domains = HashSet::new();
6341 for reference in &refs {
6342 if let Ok(Some(output)) = self
6343 .storage
6344 .find_output(&reference.txid, reference.output_index, None, None, false)
6345 .await
6346 {
6347 if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
6348 if let Some(ref our_url) = self.config.hosting_url {
6352 if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
6353 || crate::gasp::peer_origin(&domain)
6354 == crate::gasp::peer_origin(our_url)
6355 {
6356 continue;
6357 }
6358 }
6359 domains.insert(domain);
6360 }
6361 }
6362 }
6363
6364 crate::gasp::ship_peers_by_origin(domains)
6368 }
6369
6370 pub fn storage(&self) -> &dyn Storage {
6376 self.storage.as_ref()
6377 }
6378
6379 pub fn config(&self) -> &EngineConfig {
6381 &self.config
6382 }
6383}
6384
6385pub(crate) async fn verify_spv_like_the_reference(
6402 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6403 verify_scripts: bool,
6404 beef_bytes: &[u8],
6405 subject_txid: &str,
6406 budget: DoorBudget,
6407) -> Result<Option<WalkCouldNotRun>, EngineError> {
6408 verify_spv_trusting(
6409 chain_tracker,
6410 verify_scripts,
6411 beef_bytes,
6412 subject_txid,
6413 &HashSet::new(),
6414 budget,
6415 )
6416 .await
6417}
6418
6419async fn verify_spv_trusting(
6424 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6425 verify_scripts: bool,
6426 beef_bytes: &[u8],
6427 subject_txid: &str,
6428 trusted: &HashSet<String>,
6429 budget: DoorBudget,
6430) -> Result<Option<WalkCouldNotRun>, EngineError> {
6431 if verify_scripts {
6432 Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
6433 } else {
6434 Engine::verify_spv_structurally(chain_tracker, beef_bytes)
6435 .await
6436 .map(|()| None)
6437 }
6438}
6439
6440fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
6444 let mut proven: HashMap<&str, usize> = HashMap::new();
6445 for (at, bump) in beef.bumps.iter().enumerate() {
6446 for leaf in bump.path.first().into_iter().flatten() {
6447 if let Some(hash) = leaf.hash.as_deref() {
6448 proven.entry(hash).or_insert(at);
6449 }
6450 }
6451 }
6452 proven
6453}
6454
6455fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
6460 let bodies: HashMap<String, &Transaction> = beef
6461 .txs
6462 .iter()
6463 .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
6464 .collect();
6465 let proven = proven_index(beef);
6466 let mut cover = HashSet::new();
6467 let mut queue = vec![from.to_string()];
6468 while let Some(txid) = queue.pop() {
6469 if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
6470 continue;
6471 }
6472 if let Some(tx) = bodies.get(&txid) {
6473 queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
6474 }
6475 }
6476 cover
6477}
6478
6479struct SubmitAsFinalized<'e> {
6482 engine: &'e Engine,
6483 sink: crate::gasp_overlay::FinalizedGraphSink,
6484 peer_url: &'e str,
6485 topic: &'e str,
6486 submitted: &'e std::cell::Cell<u64>,
6487}
6488
6489#[async_trait::async_trait(?Send)]
6490impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
6491 async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
6492 let landed = self
6496 .engine
6497 .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
6498 .await;
6499 if landed {
6500 Ok(())
6501 } else {
6502 Err(crate::gasp::GASPError::StorageError(
6505 "a finalize submit did not land".to_string(),
6506 ))
6507 }
6508 }
6509}
6510
6511#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6515pub struct GASPSyncResult {
6516 #[serde(default, flatten)]
6517 pub cost: crate::meter::SyncCost,
6518 #[serde(default)]
6519 pub filter: crate::sync_load::SyncFilter,
6520 #[serde(default)]
6521 pub topics_filtered: Vec<String>,
6522 pub topics_synced: HashMap<String, TopicSyncResult>,
6524}
6525
6526#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6528pub struct TopicSyncResult {
6529 #[serde(default)]
6530 pub peer_results: HashMap<String, crate::backfill::PeerSyncResult>,
6531 #[serde(default)]
6532 pub dead_graphs: u64,
6533 #[serde(default)]
6534 pub expired_swept: u64,
6535 #[serde(default)]
6536 pub memo_folded: u64,
6537 #[serde(default, flatten)]
6538 pub cost: crate::meter::SyncCost,
6539 #[serde(default)]
6540 pub root_bounds: Vec<crate::sync_load::RootBound>,
6541 #[serde(default)]
6542 pub peers_skipped: Vec<crate::sync_load::SkippedPeer>,
6543 #[serde(default)]
6544 pub peers_filtered: Vec<String>,
6545 #[serde(default)]
6547 pub roots_proven: u64,
6548 #[serde(default)]
6549 pub roots_unproven: u64,
6550 #[serde(default)]
6552 pub cursor_held_by: Vec<String>,
6553 #[serde(default)]
6555 pub expired_refused: u64,
6556 pub peers: Vec<String>,
6558 pub sync_type: String,
6560 pub errors: Vec<String>,
6566 #[serde(default)]
6572 pub pruned_inputs: u64,
6573 #[serde(default)]
6578 pub discarded_graphs: u64,
6579 #[serde(default)]
6585 pub anchor_walk_held_graphs: u64,
6586 #[serde(default)]
6596 pub finalized_graphs: u64,
6597 #[serde(default)]
6605 pub deadline_dropped_graphs: u64,
6606 #[serde(default)]
6611 pub cursor_moves: Vec<CursorMove>,
6612 #[serde(default)]
6616 pub deferred_graphs: u64,
6617 #[serde(default)]
6619 pub resumed_graphs: u64,
6620 #[serde(default)]
6623 pub converged_graphs: u64,
6624 #[serde(default)]
6626 pub dropped_graphs: Vec<DroppedDeferral>,
6627 #[serde(default)]
6632 pub stalled_graphs: u64,
6633 #[serde(default)]
6637 pub held_back_graphs: u64,
6638 #[serde(default)]
6643 pub backed_off_graphs: u64,
6644 #[serde(default)]
6647 pub waiting_graphs: u64,
6648 #[serde(default)]
6653 pub idle_counted: u64,
6654 #[serde(default)]
6660 pub unanchorable_graphs: u64,
6661 #[serde(default)]
6667 pub absent_struck: u64,
6668 #[serde(default)]
6673 pub route_faults: Vec<crate::route_fault::RouteFaultSeen>,
6674 #[serde(default)]
6677 pub unanchorable_cleared: u64,
6678 #[serde(default)]
6683 pub strikes_refused: u64,
6684 #[serde(default)]
6690 pub faulted_roots: Vec<crate::gasp::FaultedRoot>,
6691}
6692
6693#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6696pub struct DroppedDeferral {
6697 pub peer: String,
6699 pub outpoint: String,
6701 pub reason: String,
6703}
6704
6705#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6708pub struct CursorMove {
6709 pub peer: String,
6711 pub from: u64,
6713 pub to: u64,
6715}
6716
6717pub(crate) fn current_timestamp_ms() -> f64 {
6722 #[cfg(target_arch = "wasm32")]
6723 {
6724 js_sys::Date::now()
6725 }
6726 #[cfg(not(target_arch = "wasm32"))]
6727 {
6728 std::time::SystemTime::now()
6729 .duration_since(std::time::UNIX_EPOCH)
6730 .map_or(0.0, |d| d.as_millis() as f64)
6731 }
6732}
6733
6734fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
6740 parse_advertisement_domain_from_script(output_script, b"SHIP", None)
6741}
6742
6743fn parse_lookup_advertisement(
6744 output: &OutputListItem,
6745 protocol: &[u8],
6746 scope: &str,
6747) -> Option<String> {
6748 let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
6749 .ok()?;
6750 let script = tx
6751 .outputs
6752 .get(output.output_index as usize)?
6753 .locking_script
6754 .to_binary();
6755 parse_advertisement_domain_from_script(&script, protocol, Some(scope))
6756}
6757
6758fn parse_advertisement_domain_from_script(
6759 output_script: &[u8],
6760 protocol: &[u8],
6761 scope: Option<&str>,
6762) -> Option<String> {
6763 use bsv_rs::script::templates::PushDrop;
6764
6765 let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
6766 let pushdrop = PushDrop::decode(&script.into()).ok()?;
6767
6768 if pushdrop.fields.len() < 3 {
6769 return None;
6770 }
6771
6772 if pushdrop.fields[0] != protocol {
6773 return None;
6774 }
6775
6776 if scope.is_some_and(|expected| {
6777 pushdrop
6778 .fields
6779 .get(3)
6780 .is_none_or(|actual| actual != expected.as_bytes())
6781 }) {
6782 return None;
6783 }
6784
6785 let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
6786 if domain.is_empty() {
6787 return None;
6788 }
6789
6790 Some(domain)
6791}
6792
6793#[derive(Debug, thiserror::Error)]
6802pub enum EngineError {
6803 #[error("This server does not support this topic: {0}")]
6804 UnsupportedTopic(String),
6805
6806 #[error("Lookup service not found for provider: {0}")]
6807 LookupServiceNotFound(String),
6808
6809 #[error("No matching output found!")]
6812 NodeNotFound,
6813
6814 #[error("lookup failed: {0}")]
6815 LookupFailed(String),
6816
6817 #[error("invalid query: {0}")]
6824 InvalidQuery(String),
6825
6826 #[error("storage error: {0}")]
6827 StorageError(String),
6828
6829 #[error("broadcast error: {0}")]
6830 BroadcastError(String),
6831
6832 #[error("SPV verification failed: {0}")]
6833 SpvError(String),
6834
6835 #[error("BEEF parsing failed: {0}")]
6836 BeefParseError(String),
6837
6838 #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6848 ScriptVerificationFailed {
6849 subject_txid: String,
6850 input_index: u32,
6851 reason: String,
6852 },
6853
6854 #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6861 ScriptWalkInconclusive {
6862 at_txid: String,
6863 subject_judged: bool,
6864 reason: String,
6865 },
6866
6867 #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6872 ScriptWalkOverBudget {
6873 at_txid: String,
6874 subject_judged: bool,
6875 limb: DoorLimb,
6876 what: String,
6877 },
6878
6879 #[error(
6889 "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6890 .0.limb.as_str(),
6891 .0.what
6892 )]
6893 WalkCouldNotRun(WalkCouldNotRun),
6894
6895 #[error("{0}")]
6896 Other(String),
6897}
6898
6899impl From<StorageError> for EngineError {
6900 fn from(e: StorageError) -> Self {
6901 EngineError::StorageError(e.to_string())
6902 }
6903}
6904
6905impl From<TopicManagerError> for EngineError {
6906 fn from(e: TopicManagerError) -> Self {
6907 EngineError::Other(e.to_string())
6908 }
6909}
6910
6911impl From<LookupServiceError> for EngineError {
6912 fn from(e: LookupServiceError) -> Self {
6913 EngineError::LookupFailed(e.to_string())
6914 }
6915}
6916
6917impl From<AdvertiserError> for EngineError {
6918 fn from(e: AdvertiserError) -> Self {
6919 EngineError::Other(e.to_string())
6920 }
6921}
6922
6923pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6933 let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6934 order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6935 order
6936}
6937
6938#[cfg(test)]
6939mod tests {
6940 use super::*;
6941 use crate::lookup_service::LookupService as LookupServiceTrait;
6942 use crate::storage::memory::MemoryStorage;
6943 use crate::topic_manager::TopicManager as TopicManagerTrait;
6944 use async_trait::async_trait;
6945 use std::sync::Mutex;
6946
6947 #[test]
6951 fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6952 let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6953 let config: SyncConfiguration = HashMap::from([
6954 ("tm_a".to_string(), SyncTarget::Ship),
6955 ("tm_z".to_string(), peers("https://z")),
6956 ("tm_b".to_string(), SyncTarget::Ship),
6957 ("tm_m".to_string(), peers("https://m")),
6958 ("tm_off".to_string(), SyncTarget::Disabled),
6959 ]);
6960 let order: Vec<&str> = sync_order(&config)
6961 .iter()
6962 .map(|(t, _)| t.as_str())
6963 .collect();
6964 assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6965 }
6966
6967 struct MockTopicManager {
6970 admit_indices: Vec<u32>,
6971 }
6972
6973 impl MockTopicManager {
6974 fn admitting(indices: Vec<u32>) -> Self {
6975 Self {
6976 admit_indices: indices,
6977 }
6978 }
6979 }
6980
6981 #[async_trait(?Send)]
6982 impl TopicManagerTrait for MockTopicManager {
6983 async fn identify_admissible_outputs(
6984 &self,
6985 _tx: &Transaction,
6986 _previous_coins: &[u8],
6987 _off_chain_values: Option<&[u8]>,
6988 _mode: SubmitMode,
6989 _context: &TopicAdmittanceContext,
6990 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6991 Ok(AdmittanceInstructions {
6992 outputs_to_admit: self.admit_indices.clone(),
6993 coins_to_retain: vec![],
6994 coins_removed: None,
6995 })
6996 }
6997
6998 async fn get_documentation(&self) -> String {
6999 "Mock topic manager".to_string()
7000 }
7001
7002 async fn get_metadata(&self) -> ServiceMetadata {
7003 ServiceMetadata {
7004 name: "mock-tm".to_string(),
7005 description: Some("Mock for testing".to_string()),
7006 ..Default::default()
7007 }
7008 }
7009 }
7010
7011 struct MockLookupService {
7014 records: Mutex<Vec<UTXOReference>>,
7015 }
7016
7017 impl MockLookupService {
7018 fn new() -> Self {
7019 Self {
7020 records: Mutex::new(Vec::new()),
7021 }
7022 }
7023 }
7024
7025 #[async_trait(?Send)]
7026 impl LookupServiceTrait for MockLookupService {
7027 fn admission_mode(&self) -> AdmissionMode {
7028 AdmissionMode::LockingScript
7029 }
7030
7031 fn spend_notification_mode(&self) -> SpendNotificationMode {
7032 SpendNotificationMode::None
7033 }
7034
7035 async fn output_admitted_by_topic(
7036 &self,
7037 payload: &OutputAdmittedByTopic,
7038 ) -> Result<(), LookupServiceError> {
7039 let (txid, oi) = match payload {
7040 OutputAdmittedByTopic::LockingScript {
7041 txid, output_index, ..
7042 } => (txid.clone(), *output_index),
7043 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
7044 ("whole".into(), *output_index)
7045 }
7046 };
7047 self.records.lock().unwrap().push(UTXOReference {
7048 txid,
7049 output_index: oi,
7050 });
7051 Ok(())
7052 }
7053
7054 async fn output_evicted(
7055 &self,
7056 txid: &str,
7057 output_index: u32,
7058 ) -> Result<(), LookupServiceError> {
7059 self.records
7060 .lock()
7061 .unwrap()
7062 .retain(|r| !(r.txid == txid && r.output_index == output_index));
7063 Ok(())
7064 }
7065
7066 async fn lookup(
7067 &self,
7068 _question: &LookupQuestion,
7069 ) -> Result<LookupResult, LookupServiceError> {
7070 Ok(LookupResult::OutputList(
7071 self.records.lock().unwrap().clone(),
7072 ))
7073 }
7074
7075 async fn get_documentation(&self) -> String {
7076 "Mock lookup service".to_string()
7077 }
7078
7079 async fn get_metadata(&self) -> ServiceMetadata {
7080 ServiceMetadata {
7081 name: "mock-ls".to_string(),
7082 ..Default::default()
7083 }
7084 }
7085 }
7086
7087 const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
7091 const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
7092
7093 fn decode_hex(hex: &str) -> Vec<u8> {
7094 (0..hex.len())
7095 .step_by(2)
7096 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
7097 .collect()
7098 }
7099
7100 fn test_beef() -> Vec<u8> {
7101 decode_hex(TEST_BEEF_HEX)
7102 }
7103
7104 fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
7105 TaggedBEEF::new(
7106 test_beef(),
7107 topics.into_iter().map(str::to_string).collect(),
7108 )
7109 }
7110
7111 fn make_engine(admit_indices: Vec<u32>) -> Engine {
7114 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7115 managers.insert(
7116 "tm_test".to_string(),
7117 Box::new(MockTopicManager::admitting(admit_indices)),
7118 );
7119
7120 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7121 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7122
7123 let storage = Box::new(MemoryStorage::new());
7124
7125 Engine::new(
7126 managers,
7127 lookup_services,
7128 storage,
7129 None,
7130 EngineConfig::default(),
7131 )
7132 }
7133
7134 #[tokio::test]
7143 async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
7144 use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
7145 use std::cell::RefCell;
7146 use std::rc::Rc;
7147
7148 let parsed = Beef::from_binary(&test_beef()).unwrap();
7151 let parent = parsed.txs[0].tx().unwrap().clone();
7152 let child = parsed.txs[1].tx().unwrap().clone();
7153 let child_id = child.id();
7154 let parent_id = parent.id();
7155 let mut beef = Beef::new();
7156 for bump in &parsed.bumps {
7162 beef.merge_bump(bump.clone());
7163 }
7164 beef.merge_transaction(child);
7165 beef.merge_transaction(parent);
7166 let mut w = bsv_rs::primitives::encoding::Writer::new();
7167 w.write_u32_le(ATOMIC_BEEF);
7168 let mut le = hex::decode(&child_id).unwrap();
7169 le.reverse();
7170 w.write_bytes(&le);
7171 beef.to_writer(&mut w);
7172 let atomic_wire_order = w.into_bytes();
7173
7174 struct RecordingTm(Rc<RefCell<Option<String>>>);
7176 #[async_trait(?Send)]
7177 impl TopicManagerTrait for RecordingTm {
7178 async fn identify_admissible_outputs(
7179 &self,
7180 tx: &Transaction,
7181 _previous_coins: &[u8],
7182 _off_chain_values: Option<&[u8]>,
7183 _mode: SubmitMode,
7184 _context: &TopicAdmittanceContext,
7185 ) -> Result<AdmittanceInstructions, TopicManagerError> {
7186 *self.0.borrow_mut() = Some(tx.id());
7187 Ok(AdmittanceInstructions {
7188 outputs_to_admit: vec![0],
7189 coins_to_retain: vec![],
7190 coins_removed: None,
7191 })
7192 }
7193 async fn get_documentation(&self) -> String {
7194 String::new()
7195 }
7196 async fn get_metadata(&self) -> ServiceMetadata {
7197 ServiceMetadata {
7198 name: "tm_test".to_string(),
7199 ..Default::default()
7200 }
7201 }
7202 }
7203
7204 let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
7205 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7206 managers.insert(
7207 "tm_test".to_string(),
7208 Box::new(RecordingTm(Rc::clone(&seen))),
7209 );
7210 let engine = Engine::new(
7211 managers,
7212 HashMap::new(),
7213 Box::new(MemoryStorage::new()),
7214 None,
7215 EngineConfig::default(),
7216 );
7217
7218 let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
7219 let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
7220
7221 let handed = seen.borrow().clone().expect("TM was not invoked");
7222 assert_eq!(
7223 handed, child_id,
7224 "TM must be handed the atomic subject, got the wire-order pick"
7225 );
7226 assert_ne!(handed, parent_id);
7227 }
7228
7229 #[tokio::test]
7242 async fn history_hydration_preserves_the_stored_proof() {
7243 use bsv_rs::transaction::Beef;
7244
7245 let engine = make_engine(vec![0]);
7246 let stored = test_beef();
7247 let bumps_before = Beef::from_binary(&stored)
7248 .expect("fixture BEEF parses")
7249 .bumps
7250 .len();
7251 assert!(
7252 bumps_before > 0,
7253 "fixture must actually carry a proof, or this cell proves nothing"
7254 );
7255
7256 let output = Output {
7257 txid: TEST_TXID.to_string(),
7258 output_index: 0,
7259 output_script: vec![0x76, 0xa9],
7260 satoshis: 1000,
7261 topic: "tm_test".to_string(),
7262 spent: false,
7263 outputs_consumed: vec![],
7266 consumed_by: vec![],
7267 beef: Some(stored.clone()),
7268 block_height: None,
7269 score: Some(1000.0),
7270 };
7271
7272 let hydrated = engine
7273 .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
7274 .await
7275 .expect("hydration must not error")
7276 .expect("hydration must return the output");
7277 let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
7278 .expect("hydrated BEEF parses");
7279
7280 assert_eq!(
7281 beef.bumps.len(),
7282 bumps_before,
7283 "asking for history must not strip the merkle proof (issue #4)"
7284 );
7285 }
7286
7287 #[tokio::test]
7288 async fn test_submit_unsupported_topic_errors() {
7289 let engine = make_engine(vec![0]);
7290 let beef = test_tagged_beef(vec!["tm_nonexistent"]);
7291
7292 let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
7293 assert!(result.is_err());
7294 assert!(matches!(
7295 result.unwrap_err(),
7296 EngineError::UnsupportedTopic(_)
7297 ));
7298 }
7299
7300 #[tokio::test]
7301 async fn test_submit_admits_outputs() {
7302 let engine = make_engine(vec![0, 1]);
7303 let beef = test_tagged_beef(vec!["tm_test"]);
7304
7305 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7306
7307 let instructions = steak.get("tm_test").unwrap();
7309 assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
7310
7311 let txid = TEST_TXID;
7313 let found = engine
7314 .storage()
7315 .find_output(txid, 0, Some("tm_test"), None, true)
7316 .await
7317 .unwrap()
7318 .expect("Output 0 should be in storage");
7319
7320 assert_eq!(found.txid, TEST_TXID);
7321 assert_eq!(found.satoshis, 26172); assert!(
7323 !found.output_script.is_empty(),
7324 "Script should be populated"
7325 );
7326 assert!(found.beef.is_some(), "BEEF should be stored");
7327 assert!(!found.spent);
7328 }
7329
7330 #[tokio::test]
7331 async fn test_submit_duplicate_is_skipped() {
7332 let engine = make_engine(vec![0]);
7333 let beef = test_tagged_beef(vec!["tm_test"]);
7334
7335 let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7337 assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
7338
7339 let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7341 assert!(steak2["tm_test"].outputs_to_admit.is_empty());
7342 }
7343
7344 #[tokio::test]
7345 async fn test_submit_notifies_lookup_service() {
7346 let engine = make_engine(vec![0]);
7347 let beef = test_tagged_beef(vec!["tm_test"]);
7348
7349 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7350
7351 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7353 let answer = engine.lookup(&question, None).await.unwrap();
7354
7355 match answer {
7356 LookupAnswer::OutputList { outputs } => {
7357 assert_eq!(outputs.len(), 1);
7358 }
7359 _ => panic!("Expected OutputList"),
7360 }
7361 }
7362
7363 #[tokio::test]
7367 async fn test_lookup_with_txids_aligns_txids_with_outputs() {
7368 let engine = make_engine(vec![0]);
7369 let beef = test_tagged_beef(vec!["tm_test"]);
7370 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7371
7372 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7373 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7374 match answer {
7375 LookupAnswer::OutputList { outputs } => {
7376 assert_eq!(outputs.len(), 1);
7377 assert_eq!(txids.len(), outputs.len(), "one txid per output");
7378 assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
7379 }
7380 _ => panic!("Expected OutputList"),
7381 }
7382 }
7383
7384 #[tokio::test]
7385 async fn proof_window_holds_an_aged_child_with_a_proven_parent() {
7386 let mut engine = make_engine(vec![0]);
7387 let storage = std::rc::Rc::new(MemoryStorage::new());
7388 engine.storage = Box::new(storage.clone());
7389 engine
7390 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7391 .await
7392 .unwrap();
7393 assert!(beef_limits::own_proof(&test_beef(), TEST_TXID)
7394 .unwrap()
7395 .is_none());
7396 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7397 storage.advance_clock((crate::proof_window::PROOFLESS_FLAG_MS / 1000) as u64);
7398 assert_eq!(
7399 engine
7400 .lookup_with_txids(&question, None)
7401 .await
7402 .unwrap()
7403 .1
7404 .len(),
7405 1
7406 );
7407 storage.advance_clock(1);
7408 assert!(
7409 engine
7410 .lookup_with_txids(&question, None)
7411 .await
7412 .unwrap()
7413 .1
7414 .is_empty(),
7415 "an aged unproven child must be held even when its parent is proven"
7416 );
7417 }
7418
7419 #[tokio::test]
7420 async fn proof_window_peer_proof_verifies_then_restores_lookup() {
7421 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7422 let mut engine = make_engine(vec![0]);
7423 let storage = std::rc::Rc::new(MemoryStorage::new());
7424 engine.storage = Box::new(storage.clone());
7425 engine
7426 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7427 .await
7428 .unwrap();
7429 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7430 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7431 let candidate = HeldOutput {
7432 txid: TEST_TXID.into(),
7433 output_index: 0,
7434 topic: "tm_test".into(),
7435 };
7436 let (_, mut remote) = budget_page_engine(1);
7437 remote.node_cost_ms = 0;
7438 remote.nodes = std::rc::Rc::new(HashMap::from([(
7439 TEST_TXID.into(),
7440 crate::types::GASPNode {
7441 graph_id: String::new(),
7442 raw_tx: String::new(),
7443 output_index: 0,
7444 proof: Some(single_leaf_bump_hex(TEST_TXID, 800_000)),
7445 tx_metadata: None,
7446 output_metadata: None,
7447 inputs: None,
7448 },
7449 )]));
7450 engine
7451 .config
7452 .sync_configuration
7453 .insert("tm_test".into(), SyncTarget::Ship);
7454 engine.ship_bootstrap_peers.insert(
7455 "tm_test".into(),
7456 vec!["https://a.example".into(), "https://b.example".into()],
7457 );
7458 engine.set_gasp_remote_factory(Box::new(remote));
7459 let progress = std::cell::Cell::new(PeerProofSummary::default());
7460 engine.chain_tracker = Some(Box::new(bsv_rs::transaction::MockChainTracker::new(
7461 800_001,
7462 )));
7463 engine
7464 .rerequest_held_proofs(std::slice::from_ref(&candidate), &progress)
7465 .await;
7466 assert_eq!(progress.get().requests, 2, "ask every topic peer");
7467 assert_eq!(progress.get().landed, 0);
7468 assert_eq!(progress.get().refused, 2);
7469 assert!(engine
7470 .lookup_with_txids(&question, None)
7471 .await
7472 .unwrap()
7473 .1
7474 .is_empty());
7475 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7476 tracker.add_root(800_000, TEST_TXID.into());
7477 engine.chain_tracker = Some(Box::new(tracker));
7478 engine.rerequest_held_proofs(&[candidate], &progress).await;
7479 assert_eq!(progress.get().requests, 4);
7480 assert_eq!(
7481 progress.get().landed,
7482 1,
7483 "count durable proofs, not peer answers"
7484 );
7485 let (_, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7486 assert_eq!(txids, vec![TEST_TXID]);
7487 let stored = storage
7488 .find_output(TEST_TXID, 0, None, None, true)
7489 .await
7490 .unwrap()
7491 .unwrap();
7492 assert!(
7493 beef_limits::own_proof(stored.beef.as_ref().unwrap(), TEST_TXID)
7494 .unwrap()
7495 .is_some()
7496 );
7497 assert_eq!(storage.proven_at_height(TEST_TXID), Some(Some(800_000)));
7498 }
7499
7500 #[tokio::test]
7501 async fn proof_window_peer_hash_leaf_is_stored_as_own_proof() {
7502 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7503 let mut engine = make_engine(vec![0]);
7504 let storage = std::rc::Rc::new(MemoryStorage::new());
7505 engine.storage = Box::new(storage.clone());
7506 engine
7507 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7508 .await
7509 .unwrap();
7510 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7511 let mut proof =
7512 bsv_rs::transaction::MerklePath::from_hex(&single_leaf_bump_hex(TEST_TXID, 800_000))
7513 .unwrap();
7514 proof.path[0][0].txid = false;
7515 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7516 tracker.add_root(800_000, proof.compute_root(Some(TEST_TXID)).unwrap());
7517 engine.chain_tracker = Some(Box::new(tracker));
7518 let (_, mut remote) = budget_page_engine(1);
7519 remote.node_cost_ms = 0;
7520 remote.nodes = std::rc::Rc::new(HashMap::from([(
7521 TEST_TXID.into(),
7522 crate::types::GASPNode {
7523 graph_id: String::new(),
7524 raw_tx: String::new(),
7525 output_index: 0,
7526 proof: Some(proof.to_hex()),
7527 tx_metadata: None,
7528 output_metadata: None,
7529 inputs: None,
7530 },
7531 )]));
7532 engine.config.sync_configuration.insert(
7533 "tm_test".into(),
7534 SyncTarget::Peers(vec!["https://peer.example".into()]),
7535 );
7536 engine.set_gasp_remote_factory(Box::new(remote));
7537 let progress = std::cell::Cell::new(PeerProofSummary::default());
7538 engine
7539 .rerequest_held_proofs(
7540 &[HeldOutput {
7541 txid: TEST_TXID.into(),
7542 output_index: 0,
7543 topic: "tm_test".into(),
7544 }],
7545 &progress,
7546 )
7547 .await;
7548 assert_eq!(progress.get().landed, 1);
7549 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7550 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7551 assert_eq!(txids, vec![TEST_TXID]);
7552 let LookupAnswer::OutputList { outputs } = answer else {
7553 panic!("expected outputs")
7554 };
7555 assert!(
7556 beef_limits::own_proof(&outputs[0].beef, TEST_TXID)
7557 .unwrap()
7558 .is_some(),
7559 "a proven flag must have the child's own path in the served BEEF"
7560 );
7561 }
7562
7563 #[tokio::test]
7564 async fn proof_window_silent_peers_yield_at_the_request_budget() {
7565 use crate::proof_window::{HeldOutput, PeerProofSummary, PEER_PROOF_REQUEST_MS};
7566 let (mut engine, remote) = budget_page_engine(1);
7567 engine.config.sync_configuration.insert(
7568 "tm_test".into(),
7569 SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
7570 );
7571 let elapsed = remote.elapsed.clone();
7572 engine.set_peer_sync_budget(
7573 std::rc::Rc::new(move |ms| {
7574 assert_eq!(ms, PEER_PROOF_REQUEST_MS);
7575 let deadline = elapsed.get() + ms;
7576 let elapsed = elapsed.clone();
7577 Box::pin(std::future::poll_fn(move |_| {
7578 if elapsed.get() >= deadline {
7579 std::task::Poll::Ready(())
7580 } else {
7581 std::task::Poll::Pending
7582 }
7583 }))
7584 }),
7585 30_000,
7586 );
7587 let candidate = HeldOutput {
7588 txid: remote.outputs[0].txid.clone(),
7589 output_index: 0,
7590 topic: "tm_test".into(),
7591 };
7592 engine.set_gasp_remote_factory(Box::new(remote));
7593 let progress = std::cell::Cell::new(PeerProofSummary::default());
7594 engine.rerequest_held_proofs(&[candidate], &progress).await;
7595 assert_eq!(progress.get().requests, 2);
7596 assert_eq!(progress.get().faults, 2);
7597 assert_eq!(progress.get().landed, 0);
7598 }
7599
7600 #[test]
7602 fn sync_limit_clamp() {
7603 assert_eq!(
7604 Engine::clamp_sync_limit(None),
7605 Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
7606 "absent limit gets the default page, never unbounded"
7607 );
7608 assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
7609 assert_eq!(
7610 Engine::clamp_sync_limit(Some(u64::MAX)),
7611 Engine::SYNC_RESPONSE_MAX_LIMIT,
7612 "a huge caller-supplied limit is capped"
7613 );
7614 }
7615
7616 #[tokio::test]
7617 async fn test_lookup_unknown_service_errors() {
7618 let engine = make_engine(vec![]);
7619 let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
7620
7621 let result = engine.lookup(&question, None).await;
7622 assert!(result.is_err());
7623 assert!(matches!(
7624 result.unwrap_err(),
7625 EngineError::LookupServiceNotFound(_)
7626 ));
7627 }
7628
7629 #[tokio::test]
7630 async fn test_list_topic_managers() {
7631 let engine = make_engine(vec![]);
7632 let managers = engine.list_topic_managers().await;
7633
7634 assert_eq!(managers.len(), 1);
7635 assert!(managers.contains_key("tm_test"));
7636 assert_eq!(managers["tm_test"].name, "mock-tm");
7637 }
7638
7639 #[tokio::test]
7640 async fn test_list_lookup_service_providers() {
7641 let engine = make_engine(vec![]);
7642 let services = engine.list_lookup_service_providers().await;
7643
7644 assert_eq!(services.len(), 1);
7645 assert!(services.contains_key("ls_test"));
7646 assert_eq!(services["ls_test"].name, "mock-ls");
7647 }
7648
7649 #[tokio::test]
7650 async fn test_get_documentation() {
7651 let engine = make_engine(vec![]);
7652
7653 let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
7654 assert_eq!(tm_docs, "Mock topic manager");
7655
7656 let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
7657 assert_eq!(ls_docs, "Mock lookup service");
7658
7659 let missing = engine
7660 .get_documentation_for_topic_manager("tm_missing")
7661 .await;
7662 assert_eq!(missing, "No documentation found!");
7663 }
7664
7665 #[tokio::test]
7666 async fn test_provide_foreign_sync_response() {
7667 let engine = make_engine(vec![0]);
7668
7669 let beef = test_tagged_beef(vec!["tm_test"]);
7671 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7672
7673 let request = GASPInitialRequest {
7675 version: 1,
7676 since: 0,
7677 limit: Some(100),
7678 };
7679 let response = engine
7680 .provide_foreign_sync_response(&request, "tm_test")
7681 .await
7682 .unwrap();
7683
7684 assert_eq!(response.utxo_list.len(), 1);
7686 assert_eq!(response.utxo_list[0].txid, TEST_TXID);
7687 assert_eq!(response.since, 0);
7688 }
7689
7690 #[tokio::test]
7691 async fn test_delete_utxo_deep_simple() {
7692 let engine = make_engine(vec![0]);
7693 let beef = test_tagged_beef(vec!["tm_test"]);
7694
7695 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7696
7697 let txid = TEST_TXID;
7698 let output = engine
7699 .storage()
7700 .find_output(txid, 0, Some("tm_test"), None, false)
7701 .await
7702 .unwrap()
7703 .unwrap();
7704
7705 engine.delete_utxo_deep(&output, None).await.unwrap();
7707
7708 let found = engine
7710 .storage()
7711 .find_output(txid, 0, Some("tm_test"), None, false)
7712 .await
7713 .unwrap();
7714 assert!(found.is_none());
7715 }
7716
7717 #[tokio::test]
7718 async fn test_sync_config_defaults_to_ship() {
7719 let engine = make_engine(vec![]);
7720 let sync = &engine.config.sync_configuration;
7721
7722 assert!(sync.contains_key("tm_test"));
7724 assert!(matches!(sync["tm_test"], SyncTarget::Ship));
7725 }
7726
7727 #[tokio::test]
7728 async fn test_multiple_topics_in_single_submit() {
7729 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7730 managers.insert(
7731 "tm_alpha".to_string(),
7732 Box::new(MockTopicManager::admitting(vec![0])),
7733 );
7734 managers.insert(
7735 "tm_beta".to_string(),
7736 Box::new(MockTopicManager::admitting(vec![1])),
7737 );
7738
7739 let engine = Engine::new(
7740 managers,
7741 HashMap::new(),
7742 Box::new(MemoryStorage::new()),
7743 None,
7744 EngineConfig::default(),
7745 );
7746
7747 let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
7748
7749 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7750
7751 assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
7752 assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
7753 }
7754
7755 #[tokio::test]
7756 async fn test_evict_output_with_topic() {
7757 let engine = make_engine(vec![0]);
7758 let beef = test_tagged_beef(vec!["tm_test"]);
7759 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7760
7761 let found = engine
7763 .storage()
7764 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7765 .await
7766 .unwrap();
7767 assert!(found.is_some(), "Output should exist before eviction");
7768
7769 engine
7771 .evict_output(TEST_TXID, 0, Some("tm_test"))
7772 .await
7773 .unwrap();
7774
7775 let found = engine
7777 .storage()
7778 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7779 .await
7780 .unwrap();
7781 assert!(found.is_none(), "Output should be gone after eviction");
7782
7783 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7785 let answer = engine.lookup(&question, None).await.unwrap();
7786 match answer {
7787 LookupAnswer::OutputList { outputs } => {
7788 assert!(
7789 outputs.is_empty(),
7790 "Lookup should return empty after eviction"
7791 );
7792 }
7793 _ => panic!("Expected OutputList"),
7794 }
7795 }
7796
7797 #[tokio::test]
7798 async fn test_evict_output_without_topic() {
7799 let engine = make_engine(vec![0]);
7800 let beef = test_tagged_beef(vec!["tm_test"]);
7801 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7802
7803 engine.evict_output(TEST_TXID, 0, None).await.unwrap();
7805
7806 let found = engine
7807 .storage()
7808 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7809 .await
7810 .unwrap();
7811 assert!(
7812 found.is_none(),
7813 "Output should be gone after topic-less eviction"
7814 );
7815 }
7816
7817 #[tokio::test]
7818 async fn test_evict_nonexistent_output_is_ok() {
7819 let engine = make_engine(vec![]);
7820
7821 let result = engine
7823 .evict_output("nonexistent_txid", 99, Some("tm_test"))
7824 .await;
7825 assert!(result.is_ok(), "Evicting nonexistent output should succeed");
7826 }
7827
7828 use crate::broadcaster::Broadcaster;
7831 use std::sync::Arc;
7832
7833 type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
7834
7835 struct MockBroadcaster {
7836 calls: BroadcastCallLog,
7837 }
7838
7839 impl MockBroadcaster {
7840 fn new() -> Self {
7841 Self {
7842 calls: Arc::new(Mutex::new(Vec::new())),
7843 }
7844 }
7845
7846 #[allow(dead_code, reason = "kept for future test extension")]
7847 fn call_count(&self) -> usize {
7848 self.calls.lock().unwrap().len()
7849 }
7850
7851 #[allow(dead_code, reason = "kept for future test extension")]
7852 fn calls(&self) -> Vec<(String, Vec<String>)> {
7853 self.calls.lock().unwrap().clone()
7854 }
7855 }
7856
7857 #[async_trait(?Send)]
7858 impl Broadcaster for MockBroadcaster {
7859 async fn broadcast_to_host(
7860 &self,
7861 host_url: &str,
7862 tagged_beef: &TaggedBEEF,
7863 ) -> Result<(), String> {
7864 self.calls
7865 .lock()
7866 .unwrap()
7867 .push((host_url.to_string(), tagged_beef.topics.clone()));
7868 Ok(())
7869 }
7870 }
7871
7872 struct MockSHIPLookupService {
7878 records: Mutex<HashMap<String, Vec<UTXOReference>>>,
7880 }
7881
7882 impl MockSHIPLookupService {
7883 fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
7884 Self {
7885 records: Mutex::new(records),
7886 }
7887 }
7888 }
7889
7890 #[async_trait(?Send)]
7891 impl LookupServiceTrait for MockSHIPLookupService {
7892 fn admission_mode(&self) -> AdmissionMode {
7893 AdmissionMode::LockingScript
7894 }
7895
7896 fn spend_notification_mode(&self) -> SpendNotificationMode {
7897 SpendNotificationMode::None
7898 }
7899
7900 async fn output_admitted_by_topic(
7901 &self,
7902 _payload: &OutputAdmittedByTopic,
7903 ) -> Result<(), LookupServiceError> {
7904 Ok(())
7905 }
7906
7907 async fn output_evicted(
7908 &self,
7909 _txid: &str,
7910 _output_index: u32,
7911 ) -> Result<(), LookupServiceError> {
7912 Ok(())
7913 }
7914
7915 async fn lookup(
7916 &self,
7917 question: &LookupQuestion,
7918 ) -> Result<LookupResult, LookupServiceError> {
7919 if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
7921 let records = self.records.lock().unwrap();
7922 let mut results = Vec::new();
7923 for topic_val in topics {
7924 if let Some(topic) = topic_val.as_str() {
7925 if let Some(refs) = records.get(topic) {
7926 results.extend(refs.iter().cloned());
7927 }
7928 }
7929 }
7930 return Ok(LookupResult::OutputList(results));
7931 }
7932 Ok(LookupResult::OutputList(vec![]))
7933 }
7934
7935 async fn get_documentation(&self) -> String {
7936 "Mock SHIP lookup service".to_string()
7937 }
7938
7939 async fn get_metadata(&self) -> ServiceMetadata {
7940 ServiceMetadata {
7941 name: "mock-ls-ship".to_string(),
7942 ..Default::default()
7943 }
7944 }
7945 }
7946
7947 fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
7949 use bsv_rs::script::templates::PushDrop;
7950 use bsv_rs::PublicKey;
7951
7952 let fields = vec![
7953 b"SHIP".to_vec(),
7954 vec![0x02; 33], domain.as_bytes().to_vec(),
7956 topic.as_bytes().to_vec(),
7957 ];
7958
7959 let pubkey = PublicKey::from_hex(
7962 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7963 )
7964 .expect("valid test pubkey");
7965
7966 let pd = PushDrop::new(pubkey, fields);
7967 pd.lock().to_binary()
7968 }
7969
7970 async fn make_engine_with_broadcaster(
7973 admit_indices: Vec<u32>,
7974 broadcaster: MockBroadcaster,
7975 ship_domains: Vec<(&str, &str)>,
7976 hosting_url: Option<&str>,
7977 ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
7978 let calls = broadcaster.calls.clone();
7979
7980 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7981 managers.insert(
7982 "tm_test".to_string(),
7983 Box::new(MockTopicManager::admitting(admit_indices)),
7984 );
7985
7986 let storage = Box::new(MemoryStorage::new());
7987
7988 let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
7992
7993 for (domain, topic) in &ship_domains {
7994 let fake_txid = format!("{:064x}", {
7996 let mut h: u64 = 0;
7997 for b in domain.bytes().chain(topic.bytes()) {
7998 h = h.wrapping_mul(31).wrapping_add(u64::from(b));
7999 }
8000 h
8001 });
8002
8003 ship_records
8005 .entry(topic.to_string())
8006 .or_default()
8007 .push(UTXOReference {
8008 txid: fake_txid.clone(),
8009 output_index: 0,
8010 });
8011
8012 let script = build_ship_pushdrop_script(domain, topic);
8014
8015 let output = Output {
8016 txid: fake_txid,
8017 output_index: 0,
8018 output_script: script,
8019 satoshis: 1,
8020 topic: "tm_ship".to_string(),
8021 spent: false,
8022 outputs_consumed: vec![],
8023 consumed_by: vec![],
8024 beef: None,
8025 block_height: None,
8026 score: None,
8027 };
8028
8029 storage.insert_output(&output).await.unwrap();
8030 }
8031
8032 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8033 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
8034 lookup_services.insert(
8035 "ls_ship".to_string(),
8036 Box::new(MockSHIPLookupService::with_records(ship_records)),
8037 );
8038
8039 let config = EngineConfig {
8040 hosting_url: hosting_url.map(str::to_string),
8041 ..Default::default()
8042 };
8043
8044 let engine = Engine::with_chain_tracker(
8045 managers,
8046 lookup_services,
8047 storage,
8048 None,
8049 Some(Box::new(broadcaster)),
8050 None,
8051 config,
8052 );
8053
8054 (engine, calls)
8055 }
8056
8057 #[tokio::test]
8060 async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
8061 let broadcaster = MockBroadcaster::new();
8062 let (engine, calls) = make_engine_with_broadcaster(
8063 vec![0],
8064 broadcaster,
8065 vec![("https://peer1.example.com", "tm_test")],
8066 Some("https://self.example.com"),
8067 )
8068 .await;
8069
8070 let beef = test_tagged_beef(vec!["tm_test"]);
8071 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8072
8073 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8075
8076 let recorded = calls.lock().unwrap();
8078 assert_eq!(
8079 recorded.len(),
8080 1,
8081 "Broadcaster should be called once for the one SHIP peer"
8082 );
8083 assert_eq!(recorded[0].0, "https://peer1.example.com");
8084 }
8085
8086 #[tokio::test]
8087 async fn test_broadcaster_not_called_on_historical_tx() {
8088 let broadcaster = MockBroadcaster::new();
8089 let (engine, calls) = make_engine_with_broadcaster(
8090 vec![0],
8091 broadcaster,
8092 vec![("https://peer1.example.com", "tm_test")],
8093 None,
8094 )
8095 .await;
8096
8097 let beef = test_tagged_beef(vec!["tm_test"]);
8098 let steak = engine
8099 .submit(&beef, SubmitMode::HistoricalTx)
8100 .await
8101 .unwrap();
8102
8103 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8104
8105 assert_eq!(
8107 calls.lock().unwrap().len(),
8108 0,
8109 "Broadcaster should not be called for historical TX"
8110 );
8111 }
8112
8113 #[tokio::test]
8114 async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
8115 let broadcaster = MockBroadcaster::new();
8116 let (engine, calls) = make_engine_with_broadcaster(
8117 vec![0],
8118 broadcaster,
8119 vec![("https://peer1.example.com", "tm_test")],
8120 None,
8121 )
8122 .await;
8123
8124 let beef = test_tagged_beef(vec!["tm_test"]);
8125 let steak = engine
8126 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
8127 .await
8128 .unwrap();
8129
8130 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8131
8132 assert_eq!(
8133 calls.lock().unwrap().len(),
8134 0,
8135 "Broadcaster should not be called for historical-tx-no-spv"
8136 );
8137 }
8138
8139 #[tokio::test]
8140 async fn test_broadcaster_skips_self_hosting_url() {
8141 let broadcaster = MockBroadcaster::new();
8142 let (engine, calls) = make_engine_with_broadcaster(
8143 vec![0],
8144 broadcaster,
8145 vec![
8146 ("https://self.example.com", "tm_test"),
8147 ("https://peer2.example.com", "tm_test"),
8148 ],
8149 Some("https://self.example.com"),
8150 )
8151 .await;
8152
8153 let beef = test_tagged_beef(vec!["tm_test"]);
8154 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8155
8156 let recorded = calls.lock().unwrap();
8158 assert_eq!(
8159 recorded.len(),
8160 1,
8161 "Should broadcast to peer2 only, skipping self"
8162 );
8163 assert_eq!(recorded[0].0, "https://peer2.example.com");
8164 }
8165
8166 #[tokio::test]
8167 async fn test_broadcaster_not_called_when_no_outputs_admitted() {
8168 let broadcaster = MockBroadcaster::new();
8169 let (engine, calls) = make_engine_with_broadcaster(
8171 vec![],
8172 broadcaster,
8173 vec![("https://peer1.example.com", "tm_test")],
8174 None,
8175 )
8176 .await;
8177
8178 let beef = test_tagged_beef(vec!["tm_test"]);
8179 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8180
8181 assert!(steak["tm_test"].outputs_to_admit.is_empty());
8182
8183 assert_eq!(
8185 calls.lock().unwrap().len(),
8186 0,
8187 "Broadcaster should not be called when no outputs are admitted"
8188 );
8189 }
8190
8191 #[tokio::test]
8192 async fn test_broadcaster_not_called_when_no_ship_peers() {
8193 let broadcaster = MockBroadcaster::new();
8194 let (engine, calls) = make_engine_with_broadcaster(
8196 vec![0],
8197 broadcaster,
8198 vec![], None,
8200 )
8201 .await;
8202
8203 let beef = test_tagged_beef(vec!["tm_test"]);
8204 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8205
8206 assert_eq!(
8207 calls.lock().unwrap().len(),
8208 0,
8209 "Broadcaster should not be called when no SHIP peers exist"
8210 );
8211 }
8212
8213 struct SpendModeLookupService {
8219 mode: SpendNotificationMode,
8220 records: Mutex<Vec<UTXOReference>>,
8221 spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
8222 }
8223
8224 impl SpendModeLookupService {
8225 fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
8226 Self {
8227 mode,
8228 records: Mutex::new(Vec::new()),
8229 spent_payloads: capture,
8230 }
8231 }
8232 }
8233
8234 #[async_trait(?Send)]
8235 impl LookupServiceTrait for SpendModeLookupService {
8236 fn admission_mode(&self) -> AdmissionMode {
8237 AdmissionMode::LockingScript
8238 }
8239
8240 fn spend_notification_mode(&self) -> SpendNotificationMode {
8241 self.mode
8242 }
8243
8244 async fn output_admitted_by_topic(
8245 &self,
8246 payload: &OutputAdmittedByTopic,
8247 ) -> Result<(), LookupServiceError> {
8248 let (txid, oi) = match payload {
8249 OutputAdmittedByTopic::LockingScript {
8250 txid, output_index, ..
8251 } => (txid.clone(), *output_index),
8252 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
8253 ("whole".into(), *output_index)
8254 }
8255 };
8256 self.records.lock().unwrap().push(UTXOReference {
8257 txid,
8258 output_index: oi,
8259 });
8260 Ok(())
8261 }
8262
8263 async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
8264 self.spent_payloads.lock().unwrap().push(payload.clone());
8265 Ok(())
8266 }
8267
8268 async fn output_evicted(
8269 &self,
8270 txid: &str,
8271 output_index: u32,
8272 ) -> Result<(), LookupServiceError> {
8273 self.records
8274 .lock()
8275 .unwrap()
8276 .retain(|r| !(r.txid == txid && r.output_index == output_index));
8277 Ok(())
8278 }
8279
8280 async fn lookup(
8281 &self,
8282 _question: &LookupQuestion,
8283 ) -> Result<LookupResult, LookupServiceError> {
8284 Ok(LookupResult::OutputList(
8285 self.records.lock().unwrap().clone(),
8286 ))
8287 }
8288
8289 async fn get_documentation(&self) -> String {
8290 "SpendMode mock lookup service".to_string()
8291 }
8292
8293 async fn get_metadata(&self) -> ServiceMetadata {
8294 ServiceMetadata {
8295 name: "spend-mode-ls".to_string(),
8296 ..Default::default()
8297 }
8298 }
8299 }
8300
8301 const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
8303
8304 async fn make_spend_mode_engine(
8307 mode: SpendNotificationMode,
8308 ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
8309 let storage = MemoryStorage::new();
8310
8311 let prev_output = Output {
8313 txid: PREVIOUS_TXID.to_string(),
8314 output_index: 0,
8315 output_script: vec![0x76, 0xa9],
8316 satoshis: 26174,
8317 topic: "tm_test".to_string(),
8318 spent: false,
8319 outputs_consumed: vec![],
8320 consumed_by: vec![],
8321 beef: Some(test_beef()),
8322 block_height: None,
8323 score: Some(1000.0),
8324 };
8325 storage.insert_output(&prev_output).await.unwrap();
8326
8327 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8328 managers.insert(
8329 "tm_test".to_string(),
8330 Box::new(MockTopicManager::admitting(vec![0])),
8331 );
8332
8333 let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
8334 let ls = SpendModeLookupService::with_mode(mode, capture.clone());
8335 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8336 lookup_services.insert("ls_test".to_string(), Box::new(ls));
8337
8338 let engine = Engine::new(
8339 managers,
8340 lookup_services,
8341 Box::new(storage),
8342 None,
8343 EngineConfig::default(),
8344 );
8345
8346 (engine, capture)
8347 }
8348
8349 #[tokio::test]
8350 async fn test_spend_notification_mode_script() {
8351 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
8352
8353 let beef = test_tagged_beef(vec!["tm_test"]);
8354 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8355
8356 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8358
8359 let payloads = capture.lock().unwrap().clone();
8361 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8362
8363 match &payloads[0] {
8364 OutputSpent::Script {
8365 txid,
8366 output_index,
8367 topic,
8368 spending_txid,
8369 input_index,
8370 unlocking_script,
8371 sequence_number,
8372 ..
8373 } => {
8374 assert_eq!(txid, PREVIOUS_TXID);
8375 assert_eq!(*output_index, 0);
8376 assert_eq!(topic, "tm_test");
8377 assert_eq!(spending_txid, TEST_TXID);
8378 assert_eq!(*input_index, 0);
8380 assert!(
8382 !unlocking_script.is_empty(),
8383 "Unlocking script should be non-empty"
8384 );
8385 assert_eq!(*sequence_number, 0xffff_ffff);
8387 }
8388 other => panic!("Expected OutputSpent::Script, got: {other:?}"),
8389 }
8390 }
8391
8392 #[tokio::test]
8393 async fn test_spend_notification_mode_whole_tx() {
8394 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
8395
8396 let beef = test_tagged_beef(vec!["tm_test"]);
8397 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8398
8399 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8400
8401 let payloads = capture.lock().unwrap().clone();
8402 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8403
8404 match &payloads[0] {
8405 OutputSpent::WholeTx {
8406 txid,
8407 output_index,
8408 topic,
8409 spending_atomic_beef,
8410 ..
8411 } => {
8412 assert_eq!(txid, PREVIOUS_TXID);
8413 assert_eq!(*output_index, 0);
8414 assert_eq!(topic, "tm_test");
8415 let mut named =
8418 bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
8419 assert!(named.is_atomic());
8420 let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
8421 assert_eq!(
8422 named.txs.len(),
8423 submitted.txs.len(),
8424 "the whole body, never pruned"
8425 );
8426 let tip = crate::subject::subject_txid_of(&mut named).unwrap();
8427 assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
8428 }
8429 other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
8430 }
8431 }
8432
8433 #[tokio::test]
8434 async fn test_spend_notification_mode_txid() {
8435 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
8436
8437 let beef = test_tagged_beef(vec!["tm_test"]);
8438 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8439
8440 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8441
8442 let payloads = capture.lock().unwrap().clone();
8443 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8444
8445 match &payloads[0] {
8446 OutputSpent::Txid {
8447 txid,
8448 output_index,
8449 topic,
8450 spending_txid,
8451 } => {
8452 assert_eq!(txid, PREVIOUS_TXID);
8453 assert_eq!(*output_index, 0);
8454 assert_eq!(topic, "tm_test");
8455 assert_eq!(spending_txid, TEST_TXID);
8456 }
8457 other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
8458 }
8459 }
8460
8461 #[tokio::test]
8462 async fn test_spend_notification_mode_none() {
8463 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
8464
8465 let beef = test_tagged_beef(vec!["tm_test"]);
8466 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8467
8468 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8469
8470 let payloads = capture.lock().unwrap().clone();
8471 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8472
8473 match &payloads[0] {
8474 OutputSpent::None {
8475 txid,
8476 output_index,
8477 topic,
8478 } => {
8479 assert_eq!(txid, PREVIOUS_TXID);
8480 assert_eq!(*output_index, 0);
8481 assert_eq!(topic, "tm_test");
8482 }
8483 other => panic!("Expected OutputSpent::None, got: {other:?}"),
8484 }
8485 }
8486
8487 #[tokio::test]
8490 async fn test_start_gasp_sync_empty_config() {
8491 let engine = Engine::new(
8492 HashMap::new(),
8493 HashMap::new(),
8494 Box::new(MemoryStorage::new()),
8495 None,
8496 EngineConfig {
8497 sync_configuration: HashMap::new(),
8498 ..Default::default()
8499 },
8500 );
8501
8502 let result = engine.start_gasp_sync().await.unwrap();
8503 assert!(
8504 result.topics_synced.is_empty(),
8505 "No topics should be synced with empty config"
8506 );
8507 }
8508
8509 #[tokio::test]
8510 async fn test_start_gasp_sync_disabled_topic_skipped() {
8511 let mut sync_config: SyncConfiguration = HashMap::new();
8512 sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
8513
8514 let engine = Engine::new(
8515 HashMap::new(),
8516 HashMap::new(),
8517 Box::new(MemoryStorage::new()),
8518 None,
8519 EngineConfig {
8520 sync_configuration: sync_config,
8521 ..Default::default()
8522 },
8523 );
8524
8525 let result = engine.start_gasp_sync().await.unwrap();
8526 assert!(
8527 result.topics_synced.is_empty(),
8528 "Disabled topics should not appear in results"
8529 );
8530 }
8531
8532 #[tokio::test]
8533 async fn test_start_gasp_sync_peers_config() {
8534 let mut sync_config: SyncConfiguration = HashMap::new();
8535 sync_config.insert(
8536 "tm_test".to_string(),
8537 SyncTarget::Peers(vec![
8538 "https://peer1.example.com".to_string(),
8539 "https://peer2.example.com".to_string(),
8540 ]),
8541 );
8542
8543 let engine = Engine::new(
8544 HashMap::new(),
8545 HashMap::new(),
8546 Box::new(MemoryStorage::new()),
8547 None,
8548 EngineConfig {
8549 sync_configuration: sync_config,
8550 ..Default::default()
8551 },
8552 );
8553
8554 let result = engine.start_gasp_sync().await.unwrap();
8555 assert_eq!(result.topics_synced.len(), 1);
8556
8557 let topic_result = &result.topics_synced["tm_test"];
8558 assert_eq!(topic_result.peers.len(), 2);
8559 assert_eq!(topic_result.sync_type, "peers");
8560 assert!(topic_result.errors.is_empty());
8561 }
8562
8563 #[tokio::test]
8564 async fn test_start_gasp_sync_peers_filters_self() {
8565 let mut sync_config: SyncConfiguration = HashMap::new();
8566 sync_config.insert(
8567 "tm_test".to_string(),
8568 SyncTarget::Peers(vec![
8569 "https://self.example.com".to_string(),
8570 "https://peer2.example.com".to_string(),
8571 ]),
8572 );
8573
8574 let engine = Engine::new(
8575 HashMap::new(),
8576 HashMap::new(),
8577 Box::new(MemoryStorage::new()),
8578 None,
8579 EngineConfig {
8580 hosting_url: Some("https://self.example.com".to_string()),
8581 sync_configuration: sync_config,
8582 ..Default::default()
8583 },
8584 );
8585
8586 let result = engine.start_gasp_sync().await.unwrap();
8587 let topic_result = &result.topics_synced["tm_test"];
8588 assert_eq!(
8589 topic_result.peers.len(),
8590 1,
8591 "Self URL should be filtered out"
8592 );
8593 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
8594 }
8595
8596 #[tokio::test]
8597 async fn load_0112_quiet_peer_costs_no_request_during_backoff() {
8598 let calls = std::rc::Rc::default();
8599 let store = std::rc::Rc::new(MemoryStorage::new());
8600 let mut engine = Engine::new(
8601 HashMap::new(),
8602 HashMap::new(),
8603 Box::new(store.clone()),
8604 None,
8605 EngineConfig {
8606 sync_configuration: HashMap::from([(
8607 "tm_test".into(),
8608 SyncTarget::Peers(vec!["https://quiet.example".into()]),
8609 )]),
8610 ..Default::default()
8611 },
8612 );
8613 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8614 calls: std::rc::Rc::clone(&calls),
8615 }));
8616 for _ in 0..4 {
8617 engine.start_gasp_sync().await.unwrap();
8618 }
8619 assert_eq!(calls.borrow().len(), 3);
8620 assert_eq!(
8621 store
8622 .get_peer_sync_health("quiet.example", "tm_test")
8623 .await
8624 .unwrap()
8625 .consecutive_failures,
8626 0
8627 );
8628 store.advance_clock(60);
8629 engine.start_gasp_sync().await.unwrap();
8630 assert_eq!(calls.borrow().len(), 4);
8631 engine.start_gasp_sync().await.unwrap();
8632 assert_eq!(calls.borrow().len(), 4);
8633 engine
8634 .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8635 topics: Some(vec!["tm_test".into()]),
8636 peers: None,
8637 })
8638 .await
8639 .unwrap();
8640 assert_eq!(calls.borrow().len(), 5);
8641 engine.start_gasp_sync().await.unwrap();
8642 assert_eq!(
8643 calls.borrow().len(),
8644 6,
8645 "explicit selection reset the quiet streak"
8646 );
8647 }
8648
8649 #[tokio::test]
8650 async fn load_0112_yield_and_advertisement_reset_cadence() {
8651 let (mut engine, remote) = budget_page_engine(1);
8652 engine.set_gasp_remote_factory(Box::new(remote));
8653 engine
8654 .storage
8655 .set_peer_cadence(
8656 "budget.example",
8657 "tm_test",
8658 &crate::sync_load::PeerCadence {
8659 quiet_syncs: 8,
8660 retry_after_secs: 0,
8661 advertisement: String::new(),
8662 },
8663 )
8664 .await
8665 .unwrap();
8666 let result = engine.start_gasp_sync().await.unwrap();
8667 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
8668 assert_eq!(
8669 engine
8670 .storage
8671 .get_peer_cadence("budget.example", "tm_test")
8672 .await
8673 .unwrap()
8674 .quiet_syncs,
8675 0
8676 );
8677 engine
8678 .storage
8679 .set_peer_cadence(
8680 "budget.example",
8681 "tm_test",
8682 &crate::sync_load::PeerCadence {
8683 quiet_syncs: 8,
8684 retry_after_secs: 3_600,
8685 advertisement: "previous-advertisement".into(),
8686 },
8687 )
8688 .await
8689 .unwrap();
8690 let result = engine.start_gasp_sync().await.unwrap();
8691 assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8692 assert!(
8693 engine
8694 .storage
8695 .get_peer_cadence("budget.example", "tm_test")
8696 .await
8697 .unwrap()
8698 .quiet_syncs
8699 <= 1
8700 );
8701 }
8702
8703 #[tokio::test]
8704 async fn load_0112_an_admitted_slap_change_wakes_its_peer() {
8705 let (mut engine, mut remote) = budget_page_engine(0);
8706 remote.node_cost_ms = 0;
8707 engine.set_gasp_remote_factory(Box::new(remote.clone()));
8708 for _ in 0..3 {
8709 engine.start_gasp_sync().await.unwrap();
8710 }
8711 let skipped = engine.start_gasp_sync().await.unwrap();
8712 assert_eq!(
8713 skipped.topics_synced["tm_test"].peers_skipped[0].reason,
8714 "no_yield"
8715 );
8716 let count = remote.requests.borrow().len();
8717 let ad = discovery_output("SLAP", "https://budget.example", "ls_ship");
8718 let tx = beef_limits::transaction_from_beef(&ad.beef, None, &beef_limits::PEER_BEEF_LIMITS)
8719 .unwrap();
8720 engine
8721 .storage
8722 .insert_output(&Output {
8723 txid: tx.id(),
8724 output_index: 0,
8725 output_script: tx.outputs[0].locking_script.to_binary(),
8726 satoshis: 1,
8727 topic: "tm_slap".into(),
8728 spent: false,
8729 outputs_consumed: vec![],
8730 consumed_by: vec![],
8731 beef: None,
8732 block_height: None,
8733 score: Some(1.0),
8734 })
8735 .await
8736 .unwrap();
8737 let result = engine.start_gasp_sync().await.unwrap();
8738 assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8739 assert_eq!(remote.requests.borrow().len(), count + 1);
8740 }
8741
8742 #[tokio::test]
8743 async fn load_0112_filter_selects_only_named_topic_and_peer() {
8744 let calls = std::rc::Rc::default();
8745 let mut engine = Engine::new(
8746 HashMap::new(),
8747 HashMap::new(),
8748 Box::new(MemoryStorage::new()),
8749 None,
8750 EngineConfig {
8751 sync_configuration: ["tm_alpha", "tm_beta"]
8752 .into_iter()
8753 .map(|t| {
8754 (
8755 t.to_string(),
8756 SyncTarget::Peers(vec![
8757 "https://a.example".into(),
8758 "https://b.example".into(),
8759 ]),
8760 )
8761 })
8762 .collect(),
8763 ..Default::default()
8764 },
8765 );
8766 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8767 calls: std::rc::Rc::clone(&calls),
8768 }));
8769 let filter = crate::sync_load::SyncFilter::parse(
8770 r#"{"topics":["tm_beta"],"peers":["https://b.example"]}"#,
8771 )
8772 .unwrap();
8773 let result = engine.start_gasp_sync_filtered(&filter).await.unwrap();
8774 assert_eq!(
8775 *calls.borrow(),
8776 vec![("https://b.example".into(), "tm_beta".into())]
8777 );
8778 assert_eq!(result.topics_synced.len(), 1);
8779 calls.borrow_mut().clear();
8780 engine.start_gasp_sync().await.unwrap();
8781 assert_eq!(calls.borrow().len(), 4);
8782 calls.borrow_mut().clear();
8783 engine
8784 .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8785 topics: Some(vec![]),
8786 peers: None,
8787 })
8788 .await
8789 .unwrap();
8790 assert!(calls.borrow().is_empty());
8791 assert!(crate::sync_load::SyncFilter::parse(r#"{"peers":"bad"}"#).is_err());
8792 assert!(crate::sync_load::SyncFilter::parse("null").is_err());
8793 assert_eq!(
8794 crate::sync_load::SyncFilter::parse(" ").unwrap(),
8795 crate::sync_load::SyncFilter::default()
8796 );
8797 }
8798
8799 #[derive(Clone, Default)]
8800 struct MockShipLookupClient {
8801 records: HashMap<(String, String), Vec<OutputListItem>>,
8802 failures: HashSet<String>,
8803 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
8804 }
8805
8806 struct RecordingDiscoverySyncFactory {
8807 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
8808 }
8809
8810 impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
8811 fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8812 self.calls
8813 .borrow_mut()
8814 .push((peer.to_string(), topic.to_string()));
8815 Box::new(MockSyncRemote { utxos: Vec::new() })
8816 }
8817 }
8818
8819 #[async_trait(?Send)]
8820 impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
8821 async fn lookup(
8822 &self,
8823 host: &str,
8824 question: &LookupQuestion,
8825 ) -> Result<Vec<OutputListItem>, String> {
8826 self.calls
8827 .borrow_mut()
8828 .push((host.to_string(), question.clone()));
8829 if self.failures.contains(host) {
8830 return Err("mock tracker unavailable".to_string());
8831 }
8832 Ok(self
8833 .records
8834 .get(&(host.to_string(), question.service.clone()))
8835 .cloned()
8836 .unwrap_or_default())
8837 }
8838 }
8839
8840 fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
8841 use bsv_rs::script::templates::PushDrop;
8842 let pubkey = bsv_rs::PublicKey::from_hex(
8843 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
8844 )
8845 .unwrap();
8846 let script = PushDrop::new(
8847 pubkey,
8848 vec![
8849 protocol.as_bytes().to_vec(),
8850 vec![0x02; 33],
8851 domain.as_bytes().to_vec(),
8852 scope.as_bytes().to_vec(),
8853 ],
8854 )
8855 .lock();
8856 let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
8857 tx.outputs[0].locking_script = script;
8858 tx.merkle_path = None;
8859 tx.invalidate_caches();
8860 let beef = tx.to_atomic_beef(true).unwrap();
8861 let parsed =
8862 beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
8863 .unwrap();
8864 assert_eq!(
8865 parsed.outputs[0].locking_script,
8866 tx.outputs[0].locking_script
8867 );
8868 OutputListItem {
8869 beef,
8870 output_index: 0,
8871 context: None,
8872 }
8873 }
8874
8875 #[tokio::test]
8876 async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
8877 let (mut engine, _) = make_engine_with_broadcaster(
8878 vec![0],
8879 MockBroadcaster::new(),
8880 vec![
8881 ("https://local.example", "tm_test"),
8882 ("https://shared.example/?local", "tm_test"),
8883 ],
8884 Some("https://self.example"),
8885 )
8886 .await;
8887 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8888 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8889 let mut client = MockShipLookupClient::default();
8890 client.records.insert(
8891 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8892 vec![
8893 discovery_output("SHIP", "https://direct.example", "tm_test"),
8894 discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
8895 discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
8896 discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
8897 discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
8898 OutputListItem {
8899 beef: vec![1, 2, 3],
8900 output_index: 0,
8901 context: None,
8902 },
8903 ],
8904 );
8905 client.records.insert(
8906 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8907 vec![
8908 discovery_output("SLAP", "https://lookup.example", "ls_ship"),
8909 discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
8910 discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
8911 discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
8912 discovery_output("SLAP", "https://other-service.example", "ls_other"),
8913 ],
8914 );
8915 client.records.insert(
8916 ("https://lookup.example".to_string(), "ls_ship".to_string()),
8917 vec![
8918 discovery_output("SHIP", "https://indirect.example", "tm_test"),
8919 discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
8920 ],
8921 );
8922 let calls = client.calls.clone();
8923 engine.set_ship_lookup_client(Box::new(client));
8924 let result = engine.start_gasp_sync().await.unwrap();
8925 assert_eq!(
8926 result.topics_synced["tm_test"].peers,
8927 vec![
8928 "https://bootstrap.example",
8929 "https://direct.example",
8930 "https://indirect.example",
8931 "https://local.example",
8932 "https://shared.example",
8933 ]
8934 );
8935 let calls = calls.borrow();
8936 assert_eq!(calls.len(), 3);
8937 for (_, question) in calls.iter() {
8938 let expected = if question.service == "ls_slap" {
8939 serde_json::json!({"service": "ls_ship"})
8940 } else {
8941 serde_json::json!({"topics": ["tm_test"]})
8942 };
8943 assert_eq!(question.query, expected);
8944 }
8945 }
8946
8947 #[tokio::test]
8948 async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
8949 let (mut engine, _) =
8950 make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
8951 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8952 let mut client = MockShipLookupClient::default();
8953 let outputs = (0..9)
8954 .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
8955 .collect();
8956 client.records.insert(
8957 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8958 outputs,
8959 );
8960 engine.set_ship_lookup_client(Box::new(client));
8961 let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8962 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8963 calls: sync_calls.clone(),
8964 }));
8965 let result = engine.start_gasp_sync().await.unwrap();
8966 let topic = &result.topics_synced["tm_test"];
8967 assert_eq!(topic.sync_type, "ship");
8968 assert_eq!(
8969 topic.peers.len(),
8970 9,
8971 "the lookup-host cap must not truncate sync peers"
8972 );
8973 assert!(topic.errors.is_empty());
8974 assert_eq!(sync_calls.borrow().len(), 9);
8975 for peer in &topic.peers {
8976 assert!(sync_calls
8977 .borrow()
8978 .contains(&(peer.clone(), "tm_test".to_string())));
8979 }
8980 }
8981
8982 #[tokio::test]
8983 async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
8984 let mut engine = make_engine(vec![]);
8985 engine.config.slap_trackers = vec![
8986 "https://dead.example".to_string(),
8987 "https://live.example".to_string(),
8988 ];
8989 let mut client = MockShipLookupClient::default();
8990 client.failures.insert("https://dead.example".to_string());
8991 client.records.insert(
8992 ("https://live.example".to_string(), "ls_ship".to_string()),
8993 vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
8994 );
8995 engine.set_ship_lookup_client(Box::new(client));
8996 let result = engine.start_gasp_sync().await.unwrap();
8997 assert_eq!(
8998 result.topics_synced["tm_test"].peers,
8999 vec!["https://peer.example"]
9000 );
9001 }
9002
9003 #[tokio::test]
9004 async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
9005 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
9006 let mut engine = make_engine(vec![]);
9007 engine
9008 .config
9009 .sync_configuration
9010 .insert("tm_other".to_string(), SyncTarget::Ship);
9011 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
9012 let mut client = MockShipLookupClient::default();
9013 let mut providers = Vec::new();
9014 for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
9015 let host = format!("https://lookup{n}.example");
9016 providers.push(discovery_output("SLAP", &host, "ls_ship"));
9017 client.records.insert(
9018 (host, "ls_ship".to_string()),
9019 vec![
9020 discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
9021 discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
9022 ],
9023 );
9024 }
9025 client.records.insert(
9026 ("https://tracker.example".to_string(), "ls_slap".to_string()),
9027 providers,
9028 );
9029 let calls = client.calls.clone();
9030 engine.set_ship_lookup_client(Box::new(client));
9031 let result = engine.start_gasp_sync().await.unwrap();
9032 assert_eq!(
9033 result.topics_synced["tm_test"].peers.len(),
9034 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
9035 );
9036 assert_eq!(
9037 result.topics_synced["tm_other"].peers.len(),
9038 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
9039 );
9040 let calls = calls.borrow();
9041 assert_eq!(
9042 calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
9043 1
9044 );
9045 let providers: HashSet<_> = calls
9046 .iter()
9047 .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
9048 .map(|(h, _)| h)
9049 .collect();
9050 assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
9051 assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
9052 }
9053
9054 #[tokio::test]
9055 async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
9056 let mut engine = make_engine(vec![]);
9057 engine.config.hosting_url = Some("https://self.example".to_string());
9058 engine.config.slap_trackers = vec!["https://dead.example".to_string()];
9059 let mut client = MockShipLookupClient::default();
9060 client.failures.insert("https://dead.example".to_string());
9061 engine.set_ship_lookup_client(Box::new(client));
9062 engine.set_ship_bootstrap_peers(
9063 "tm_test",
9064 vec![
9065 "https://bootstrap.example".to_string(),
9066 "https://BOOTSTRAP.example.:443/?x".to_string(),
9067 "http://SELF.example.:8443/path".to_string(),
9068 ],
9069 );
9070 let result = engine.start_gasp_sync().await.unwrap();
9071 assert_eq!(
9072 result.topics_synced["tm_test"].peers,
9073 vec!["https://bootstrap.example"]
9074 );
9075 }
9076
9077 #[tokio::test]
9078 async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
9079 let mut engine = make_engine(vec![]);
9080 for topic in ["tm_ship", "tm_slap"] {
9081 engine.config.sync_configuration.insert(
9082 topic.to_string(),
9083 SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
9084 );
9085 }
9086 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
9087 let mut client = MockShipLookupClient::default();
9088 client.records.insert(
9089 ("https://tracker.example".to_string(), "ls_ship".to_string()),
9090 vec![discovery_output(
9091 "SHIP",
9092 "https://discovered.example",
9093 "tm_test",
9094 )],
9095 );
9096 let lookups = client.calls.clone();
9097 engine.set_ship_lookup_client(Box::new(client));
9098 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
9099 let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
9100 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
9101 calls: calls.clone(),
9102 }));
9103 engine.start_gasp_sync().await.unwrap();
9104 assert_eq!(
9105 calls
9106 .borrow()
9107 .iter()
9108 .map(|(_, topic)| topic.as_str())
9109 .collect::<Vec<_>>(),
9110 vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
9111 );
9112 assert_eq!(lookups.borrow().len(), 2);
9113 }
9114
9115 #[tokio::test]
9116 async fn test_start_gasp_sync_ship_discovers_peers() {
9117 let broadcaster = MockBroadcaster::new();
9118 let (engine, _calls) = make_engine_with_broadcaster(
9119 vec![0],
9120 broadcaster,
9121 vec![
9122 ("https://peer1.example.com", "tm_test"),
9123 ("https://peer2.example.com", "tm_test"),
9124 ],
9125 Some("https://self.example.com"),
9126 )
9127 .await;
9128
9129 let result = engine.start_gasp_sync().await.unwrap();
9130 assert!(
9131 result.topics_synced.contains_key("tm_test"),
9132 "tm_test should be in sync results"
9133 );
9134
9135 let topic_result = &result.topics_synced["tm_test"];
9136 assert_eq!(topic_result.sync_type, "ship");
9137 assert_eq!(
9138 topic_result.peers.len(),
9139 2,
9140 "Should discover both SHIP peers"
9141 );
9142 assert!(topic_result
9143 .peers
9144 .contains(&"https://peer1.example.com".to_string()));
9145 assert!(topic_result
9146 .peers
9147 .contains(&"https://peer2.example.com".to_string()));
9148 }
9149
9150 #[tokio::test]
9151 async fn test_start_gasp_sync_ship_filters_self() {
9152 let broadcaster = MockBroadcaster::new();
9153 let (engine, _calls) = make_engine_with_broadcaster(
9154 vec![0],
9155 broadcaster,
9156 vec![
9157 ("https://self.example.com", "tm_test"),
9158 ("https://peer2.example.com", "tm_test"),
9159 ],
9160 Some("https://self.example.com"),
9161 )
9162 .await;
9163
9164 let result = engine.start_gasp_sync().await.unwrap();
9165 let topic_result = &result.topics_synced["tm_test"];
9166 assert_eq!(
9167 topic_result.peers.len(),
9168 1,
9169 "Self URL should be filtered out from SHIP discovery"
9170 );
9171 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
9172 }
9173
9174 #[tokio::test]
9175 async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
9176 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9177 managers.insert(
9178 "tm_custom".to_string(),
9179 Box::new(MockTopicManager::admitting(vec![])),
9180 );
9181
9182 let mut sync_config: SyncConfiguration = HashMap::new();
9183 sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
9184
9185 let engine = Engine::new(
9186 managers,
9187 HashMap::new(),
9188 Box::new(MemoryStorage::new()),
9189 None,
9190 EngineConfig {
9191 sync_configuration: sync_config,
9192 ..Default::default()
9193 },
9194 );
9195
9196 let result = engine.start_gasp_sync().await.unwrap();
9197 let topic_result = &result.topics_synced["tm_custom"];
9198 assert!(
9199 topic_result.peers.is_empty(),
9200 "Without ls_ship, SHIP discovery should return no peers"
9201 );
9202 }
9203
9204 #[tokio::test]
9205 async fn test_start_gasp_sync_mixed_config() {
9206 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9207 managers.insert(
9208 "tm_peers".to_string(),
9209 Box::new(MockTopicManager::admitting(vec![])),
9210 );
9211 managers.insert(
9212 "tm_disabled".to_string(),
9213 Box::new(MockTopicManager::admitting(vec![])),
9214 );
9215 managers.insert(
9216 "tm_ship_topic".to_string(),
9217 Box::new(MockTopicManager::admitting(vec![])),
9218 );
9219
9220 let mut sync_config: SyncConfiguration = HashMap::new();
9221 sync_config.insert(
9222 "tm_peers".to_string(),
9223 SyncTarget::Peers(vec!["https://peer.com".to_string()]),
9224 );
9225 sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
9226 sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
9227
9228 let engine = Engine::new(
9229 managers,
9230 HashMap::new(),
9231 Box::new(MemoryStorage::new()),
9232 None,
9233 EngineConfig {
9234 sync_configuration: sync_config,
9235 ..Default::default()
9236 },
9237 );
9238
9239 let result = engine.start_gasp_sync().await.unwrap();
9240 assert_eq!(result.topics_synced.len(), 2);
9241 assert!(result.topics_synced.contains_key("tm_peers"));
9242 assert!(result.topics_synced.contains_key("tm_ship_topic"));
9243 assert!(!result.topics_synced.contains_key("tm_disabled"));
9244
9245 assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
9246 assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
9247 assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
9248 }
9249
9250 #[tokio::test]
9251 async fn test_gasp_sync_result_serialization() {
9252 let mut topics_synced = HashMap::new();
9253 topics_synced.insert(
9254 "tm_test".to_string(),
9255 super::TopicSyncResult {
9256 cost: crate::meter::SyncCost::default(),
9257 root_bounds: Vec::new(),
9258 peers_skipped: Vec::new(),
9259 peers_filtered: Vec::new(),
9260 roots_proven: 0,
9261 roots_unproven: 0,
9262 cursor_held_by: Vec::new(),
9263 expired_refused: 0,
9264 peers: vec!["https://peer.com".to_string()],
9265 sync_type: "ship".to_string(),
9266 errors: vec![],
9267 pruned_inputs: 0,
9268 discarded_graphs: 0,
9269 anchor_walk_held_graphs: 0,
9270 finalized_graphs: 0,
9271 deadline_dropped_graphs: 0,
9272 cursor_moves: Vec::new(),
9273 deferred_graphs: 0,
9274 resumed_graphs: 0,
9275 converged_graphs: 0,
9276 dropped_graphs: Vec::new(),
9277 stalled_graphs: 0,
9278 held_back_graphs: 0,
9279 backed_off_graphs: 0,
9280 waiting_graphs: 0,
9281 idle_counted: 0,
9282 peer_results: HashMap::new(),
9283 dead_graphs: 0,
9284 expired_swept: 0,
9285 memo_folded: 0,
9286 unanchorable_graphs: 0,
9287 unanchorable_cleared: 0,
9288 absent_struck: 0,
9289 route_faults: Vec::new(),
9290 strikes_refused: 0,
9291 faulted_roots: Vec::new(),
9292 },
9293 );
9294
9295 let result = super::GASPSyncResult {
9296 cost: crate::meter::SyncCost::default(),
9297 topics_synced,
9298 filter: crate::sync_load::SyncFilter::default(),
9299 topics_filtered: Vec::new(),
9300 };
9301 let json = serde_json::to_string(&result).unwrap();
9302 let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
9303
9304 assert_eq!(back.topics_synced.len(), 1);
9305 assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
9306 assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
9307 }
9308
9309 struct MockSyncRemote {
9313 utxos: Vec<crate::types::GASPOutput>,
9314 }
9315
9316 #[async_trait(?Send)]
9317 impl crate::gasp::GASPRemote for MockSyncRemote {
9318 async fn get_initial_response(
9319 &self,
9320 request: &crate::types::GASPInitialRequest,
9321 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9322 let utxos: Vec<crate::types::GASPOutput> = self
9323 .utxos
9324 .iter()
9325 .filter(|u| u.score as u64 >= request.since)
9326 .cloned()
9327 .collect();
9328 Ok(crate::types::GASPInitialResponse {
9329 utxo_list: utxos,
9330 since: request.since,
9331 })
9332 }
9333 async fn get_initial_reply(
9334 &self,
9335 _: &crate::types::GASPInitialResponse,
9336 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9337 Ok(crate::types::GASPInitialReply {
9338 utxo_list: Vec::new(),
9339 })
9340 }
9341 async fn request_node(
9342 &self,
9343 graph_id: &str,
9344 txid: &str,
9345 output_index: u32,
9346 _: bool,
9347 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9348 Ok(crate::types::GASPNode {
9349 graph_id: graph_id.to_string(),
9350 raw_tx: format!("rawtx_{txid}"),
9351 output_index,
9352 proof: None,
9353 tx_metadata: None,
9354 output_metadata: None,
9355 inputs: None,
9356 })
9357 }
9358 async fn submit_node(
9359 &self,
9360 _: &crate::types::GASPNode,
9361 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9362 Ok(None)
9363 }
9364 }
9365
9366 struct MockGASPRemoteFactory;
9368
9369 #[derive(Clone)]
9370 struct BudgetPageRemote {
9371 outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
9372 nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
9373 elapsed: std::rc::Rc<Cell<u64>>,
9374 requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
9375 clamp: Option<usize>,
9376 node_cost_ms: u64,
9377 slow_once: std::rc::Rc<std::cell::RefCell<Option<(String, u64)>>>,
9379 asked: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
9381 faults_for: std::rc::Rc<std::cell::RefCell<std::collections::HashSet<String>>>,
9383 }
9384
9385 #[async_trait(?Send)]
9386 impl crate::gasp::GASPRemote for BudgetPageRemote {
9387 async fn get_initial_response(
9388 &self,
9389 request: &crate::types::GASPInitialRequest,
9390 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9391 self.requests.borrow_mut().push(request.clone());
9392 let page = self
9393 .outputs
9394 .iter()
9395 .filter(|u| u.score as u64 >= request.since);
9396 let limit = self.clamp.map_or(usize::MAX, |cap| {
9397 cap.min(request.limit.unwrap_or(u64::MAX) as usize)
9398 });
9399 Ok(crate::types::GASPInitialResponse {
9400 utxo_list: page.take(limit).cloned().collect(),
9401 since: request.since,
9402 })
9403 }
9404
9405 async fn get_initial_reply(
9406 &self,
9407 _: &crate::types::GASPInitialResponse,
9408 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9409 unreachable!()
9410 }
9411
9412 async fn request_node(
9413 &self,
9414 graph_id: &str,
9415 txid: &str,
9416 _: u32,
9417 _: bool,
9418 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9419 self.asked.borrow_mut().push(txid.to_string());
9420 self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
9421 let slow = {
9422 let mut slow = self.slow_once.borrow_mut();
9423 match slow.as_ref() {
9424 Some((t, _)) if t == txid => slow.take().map(|(_, ms)| ms),
9425 _ => None,
9426 }
9427 };
9428 if let Some(ms) = slow {
9429 self.elapsed.set(self.elapsed.get() + ms);
9430 }
9431 if self.faults_for.borrow().contains(txid) {
9432 return Err(crate::gasp::GASPError::RemoteError("HTTP 503".into()));
9433 }
9434 if self.node_cost_ms > 0 {
9435 let mut yielded = false;
9436 std::future::poll_fn(|cx| {
9437 if yielded {
9438 std::task::Poll::Ready(())
9439 } else {
9440 yielded = true;
9441 cx.waker().wake_by_ref();
9442 std::task::Poll::Pending
9443 }
9444 })
9445 .await;
9446 }
9447 let mut node = self
9448 .nodes
9449 .get(txid)
9450 .cloned()
9451 .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
9452 node.graph_id = graph_id.to_string();
9453 Ok(node)
9454 }
9455
9456 async fn submit_node(
9457 &self,
9458 _: &crate::types::GASPNode,
9459 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9460 unreachable!()
9461 }
9462 }
9463
9464 impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
9465 fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
9466 Box::new(self.clone())
9467 }
9468 }
9469
9470 fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
9471 let mut engine = make_engine(vec![0]);
9472 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
9473 let mut outputs = Vec::new();
9474 let mut nodes = HashMap::new();
9475 for n in 1..=count {
9476 let tx = child_of(&format!("{n:064x}"), 0);
9477 let txid = tx.id();
9478 tracker.add_root(n, txid.clone());
9479 outputs.push(crate::types::GASPOutput {
9480 txid: txid.clone(),
9481 output_index: 0,
9482 score: f64::from(n * 10),
9483 });
9484 nodes.insert(
9485 txid.clone(),
9486 crate::types::GASPNode {
9487 graph_id: String::new(),
9488 raw_tx: tx.to_hex(),
9489 output_index: 0,
9490 proof: Some(single_leaf_bump_hex(&txid, n)),
9491 tx_metadata: None,
9492 output_metadata: None,
9493 inputs: None,
9494 },
9495 );
9496 }
9497 engine.chain_tracker = Some(Box::new(tracker));
9498 engine.config.sync_configuration.insert(
9499 "tm_test".to_string(),
9500 SyncTarget::Peers(vec!["https://budget.example".to_string()]),
9501 );
9502 let remote = BudgetPageRemote {
9503 outputs: std::rc::Rc::new(outputs),
9504 nodes: std::rc::Rc::new(nodes),
9505 elapsed: std::rc::Rc::new(Cell::new(0)),
9506 requests: std::rc::Rc::default(),
9507 clamp: None,
9508 node_cost_ms: 5000,
9509 slow_once: std::rc::Rc::default(),
9510 asked: std::rc::Rc::default(),
9511 faults_for: std::rc::Rc::default(),
9512 };
9513 (engine, remote)
9514 }
9515
9516 fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
9517 let elapsed = remote.elapsed.clone();
9518 engine.set_peer_sync_budget(
9519 std::rc::Rc::new(move |ms| {
9520 assert_eq!(ms, 30_000);
9521 let elapsed = elapsed.clone();
9522 let until = elapsed.get() + ms;
9523 Box::pin(std::future::poll_fn(move |_| {
9524 if elapsed.get() >= until {
9525 std::task::Poll::Ready(())
9526 } else {
9527 std::task::Poll::Pending
9528 }
9529 }))
9530 }),
9531 30_000,
9532 );
9533 }
9534
9535 #[tokio::test]
9536 async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
9537 let (mut engine, remote) = budget_page_engine(3000);
9540 virtual_peer_budget(&mut engine, &remote);
9541 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9542 for round in 1..=2 {
9543 let result = engine.start_gasp_sync().await.unwrap();
9544 let topic = &result.topics_synced["tm_test"];
9545 assert_eq!(topic.finalized_graphs, 5);
9546 assert_eq!(topic.deadline_dropped_graphs, 1);
9547 assert_eq!(topic.cursor_moves.len(), 1);
9548 let expected = round * 50;
9549 assert_eq!(topic.cursor_moves[0].to, expected);
9550 assert_eq!(
9551 engine
9552 .storage
9553 .get_last_interaction("https://budget.example", "tm_test")
9554 .await
9555 .unwrap(),
9556 expected
9557 );
9558 {
9559 let requests = remote.requests.borrow();
9560 assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
9561 }
9562 for output in remote.outputs.iter().take((round * 5) as usize) {
9563 assert!(engine
9564 .storage
9565 .find_output(&output.txid, 0, Some("tm_test"), None, true)
9566 .await
9567 .unwrap()
9568 .is_some());
9569 }
9570 let unfinished = &remote.outputs[(round * 5) as usize];
9571 assert!(engine
9572 .storage
9573 .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
9574 .await
9575 .unwrap()
9576 .is_none());
9577 }
9578 }
9579
9580 #[tokio::test]
9581 async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
9582 let (mut engine, mut remote) = budget_page_engine(7);
9583 remote.clamp = Some(3);
9584 remote.node_cost_ms = 0;
9585 virtual_peer_budget(&mut engine, &remote);
9586 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9587 let result = engine.start_gasp_sync().await.unwrap();
9588 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
9589 let requests = remote.requests.borrow();
9590 assert_eq!(requests.len(), 4);
9591 assert!(requests.iter().all(|request| request.limit == Some(100)));
9592 assert_eq!(
9593 requests.iter().map(|r| r.since).collect::<Vec<_>>(),
9594 vec![0, 30, 50, 70]
9595 );
9596 }
9597
9598 #[tokio::test]
9599 async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
9600 let (mut engine, mut remote) = budget_page_engine(3000);
9601 std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
9602 virtual_peer_budget(&mut engine, &remote);
9603 engine.set_gasp_remote_factory(Box::new(remote));
9604 let result = engine.start_gasp_sync().await.unwrap();
9605 let topic = &result.topics_synced["tm_test"];
9606 assert_eq!(topic.finalized_graphs, 5);
9607 assert!(topic.cursor_moves.is_empty());
9610 }
9611
9612 #[tokio::test]
9613 async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
9614 let (mut engine, mut remote) = budget_page_engine(3);
9615 std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
9616 remote.node_cost_ms = 10_000;
9617 virtual_peer_budget(&mut engine, &remote);
9618 engine.set_gasp_remote_factory(Box::new(remote));
9619 let result = engine.start_gasp_sync().await.unwrap();
9620 let topic = &result.topics_synced["tm_test"];
9621 assert_eq!(topic.finalized_graphs, 2);
9622 assert_eq!(topic.cursor_moves[0].to, 10);
9623 }
9624
9625 #[tokio::test]
9626 async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
9627 let (mut engine, mut remote) = budget_page_engine(3000);
9628 let failed = remote.outputs[2].txid.clone();
9629 std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
9630 virtual_peer_budget(&mut engine, &remote);
9631 engine.set_gasp_remote_factory(Box::new(remote));
9632 let result = engine.start_gasp_sync().await.unwrap();
9633 let topic = &result.topics_synced["tm_test"];
9634 assert_eq!(topic.finalized_graphs, 4);
9635 assert_eq!(topic.cursor_moves[0].to, 20);
9636 }
9637
9638 struct ProofOnlyRootFetcher {
9639 proofs: HashMap<String, String>,
9640 requested: std::rc::Rc<Cell<u64>>,
9641 }
9642
9643 #[async_trait(?Send)]
9644 impl AncestorFetcher for ProofOnlyRootFetcher {
9645 async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
9646 assert!(
9647 self.requested.get() > 0,
9648 "root proof request must precede ancestry"
9649 );
9650 Err(GASPError::RemoteError("raw source unavailable".to_string()))
9651 }
9652 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
9653 self.requested.set(self.requested.get() + 1);
9654 self.proofs.get(txid).cloned()
9655 }
9656 }
9657
9658 #[tokio::test]
9659 async fn copy_sync_leaves_completion_courier_untouched() {
9660 let (mut engine, mut remote) = budget_page_engine(1);
9661 remote.node_cost_ms = 0;
9662 let txid = remote.outputs[0].txid.clone();
9663 let proof = std::rc::Rc::make_mut(&mut remote.nodes)
9664 .get_mut(&txid)
9665 .unwrap()
9666 .proof
9667 .take()
9668 .unwrap();
9669 let completion_asks = std::rc::Rc::new(Cell::new(0));
9670 let sync_asks = std::rc::Rc::new(Cell::new(0));
9671 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9672 proofs: HashMap::new(),
9673 requested: completion_asks.clone(),
9674 }));
9675 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9676 proofs: HashMap::from([(txid.clone(), proof.clone())]),
9677 requested: sync_asks.clone(),
9678 }));
9679 engine.set_gasp_remote_factory(Box::new(remote));
9680 let result = engine.start_gasp_sync().await.unwrap();
9681 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
9682 assert_eq!(result.topics_synced["tm_test"].roots_proven, 1);
9683 assert_eq!(result.topics_synced["tm_test"].roots_unproven, 0);
9684 let stored = engine
9685 .storage()
9686 .find_output(&txid, 0, Some("tm_test"), None, true)
9687 .await
9688 .unwrap()
9689 .unwrap();
9690 assert!(
9691 crate::beef_limits::own_proof(stored.beef.as_ref().unwrap(), &txid)
9692 .unwrap()
9693 .is_some()
9694 );
9695 assert_eq!(completion_asks.get(), 0);
9696 assert_eq!(sync_asks.get(), 1);
9697 let served = engine
9698 .provide_foreign_gasp_node(&format!("{txid}.0"), &txid, 0)
9699 .await
9700 .unwrap();
9701 assert_eq!(served.proof, Some(proof));
9702 }
9703
9704 #[tokio::test]
9705 async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
9706 for proven in [true, false] {
9707 let (mut engine, mut remote) = budget_page_engine(1);
9708 let txid = remote.outputs[0].txid.clone();
9709 let proof = remote.nodes[&txid].proof.clone().unwrap();
9710 std::rc::Rc::make_mut(&mut remote.nodes)
9711 .get_mut(&txid)
9712 .unwrap()
9713 .proof = None;
9714 let requested = std::rc::Rc::new(Cell::new(0));
9715 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9716 proofs: if proven {
9717 HashMap::from([(txid.clone(), proof)])
9718 } else {
9719 HashMap::new()
9720 },
9721 requested: requested.clone(),
9722 }));
9723 virtual_peer_budget(&mut engine, &remote);
9724 engine.set_gasp_remote_factory(Box::new(remote));
9725 let result = engine.start_gasp_sync().await.unwrap();
9726 assert!(requested.get() > 0);
9727 assert_eq!(
9728 result.topics_synced["tm_test"].finalized_graphs,
9729 u64::from(proven)
9730 );
9731 assert_eq!(
9732 engine
9733 .storage
9734 .find_output(&txid, 0, Some("tm_test"), None, true)
9735 .await
9736 .unwrap()
9737 .is_some(),
9738 proven
9739 );
9740 if !proven {
9741 assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
9742 }
9743 }
9744 }
9745
9746 #[tokio::test]
9747 async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
9748 let (mut engine, mut remote) = budget_page_engine(1);
9749 let txid = remote.outputs[0].txid.clone();
9750 let parent = bsv_rs::transaction::Transaction::from_hex(&remote.nodes[&txid].raw_tx)
9754 .unwrap()
9755 .inputs[0]
9756 .source_txid
9757 .clone()
9758 .unwrap();
9759 remote.faults_for.borrow_mut().insert(parent);
9760 let proof = remote.nodes[&txid].proof.clone().unwrap();
9761 std::rc::Rc::make_mut(&mut remote.nodes)
9762 .get_mut(&txid)
9763 .unwrap()
9764 .proof = None;
9765 let requested = std::rc::Rc::new(Cell::new(0));
9766 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9767 proofs: HashMap::new(),
9768 requested: requested.clone(),
9769 }));
9770 virtual_peer_budget(&mut engine, &remote);
9771 engine.set_graph_budget(
9772 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9773 5,
9774 5000,
9775 );
9776 engine.set_gasp_remote_factory(Box::new(remote));
9777 let held = engine.start_gasp_sync().await.unwrap();
9778 assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
9779 assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
9780 assert_eq!(held.topics_synced["tm_test"].cursor_moves[0].to, 10);
9781 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9782 proofs: HashMap::from([(txid.clone(), proof)]),
9783 requested,
9784 }));
9785 engine
9786 .storage()
9787 .update_last_interaction("https://budget.example", "tm_test", 20)
9788 .await
9789 .unwrap();
9790 let landed = engine.start_gasp_sync().await.unwrap();
9791 assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
9792 assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
9793 assert!(landed.topics_synced["tm_test"].cursor_moves.is_empty());
9794 assert!(engine
9795 .storage
9796 .find_output(&txid, 0, Some("tm_test"), None, true)
9797 .await
9798 .unwrap()
9799 .is_some());
9800 }
9801
9802 #[tokio::test]
9803 async fn proofs_deadline_stalls_become_durable_after_three_runs() {
9804 let (mut engine, remote) = budget_page_engine(1);
9805 let elapsed = remote.elapsed.clone();
9806 let root = format!("{}.0", remote.outputs[0].txid);
9807 engine.set_graph_budget(
9808 std::rc::Rc::new(move |ms| {
9809 let until = elapsed.get() + ms;
9810 let elapsed = elapsed.clone();
9811 Box::pin(std::future::poll_fn(move |_| {
9812 if elapsed.get() >= until {
9813 std::task::Poll::Ready(())
9814 } else {
9815 std::task::Poll::Pending
9816 }
9817 }))
9818 }),
9819 10,
9820 1_000,
9821 );
9822 engine.set_gasp_remote_factory(Box::new(remote));
9823 for round in 1..=crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9824 let result = engine.start_gasp_sync().await.unwrap();
9825 let topic = &result.topics_synced["tm_test"];
9826 if round < crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9827 assert!(topic.cursor_moves.is_empty());
9828 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
9829 } else {
9830 assert_eq!(topic.cursor_moves[0].to, 10);
9831 assert_eq!(topic.deferred_graphs, 1);
9832 }
9833 }
9834 let held = engine
9835 .storage()
9836 .get_deferred_graph("https://budget.example", "tm_test", &root)
9837 .await
9838 .unwrap()
9839 .unwrap();
9840 assert!(held.cursor_passable);
9841 assert!(held.nodes.is_empty());
9842 }
9843
9844 #[tokio::test]
9850 async fn cursor_018_the_graph_in_flight_at_the_peer_deadline_is_held_and_passed() {
9851 let (mut engine, remote) = budget_page_engine(6);
9852 let slow = remote.outputs[2].txid.clone();
9853 let root = format!("{slow}.0");
9854 *remote.slow_once.borrow_mut() = Some((slow.clone(), 60_000));
9855 virtual_peer_budget(&mut engine, &remote);
9856 engine.set_graph_budget(
9857 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9858 100,
9859 60_000,
9860 );
9861 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9862 let result = engine.start_gasp_sync().await.unwrap();
9863 let topic = &result.topics_synced["tm_test"];
9864 assert_eq!(topic.finalized_graphs, 2);
9865 assert_eq!(topic.deadline_dropped_graphs, 1);
9866 assert_eq!(topic.deferred_graphs, 1);
9867 assert_eq!(topic.cursor_moves.len(), 1);
9868 assert_eq!(topic.cursor_moves[0].to, 30, "past the held graph");
9869 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
9870 let held = engine
9871 .storage()
9872 .get_deferred_graph("https://budget.example", "tm_test", &root)
9873 .await
9874 .unwrap()
9875 .unwrap();
9876 assert!(held.cursor_passable);
9877 assert_eq!(held.reason, "peer_deadline");
9878 assert!(held.nodes.is_empty());
9879 assert_eq!(held.score, 30);
9880
9881 let result = engine.start_gasp_sync().await.unwrap();
9882 let topic = &result.topics_synced["tm_test"];
9883 assert_eq!(topic.resumed_graphs, 1);
9884 assert_eq!(topic.converged_graphs, 1);
9885 assert_eq!(topic.finalized_graphs, 4, "the held graph and 40, 50, 60");
9886 assert_eq!(topic.cursor_moves[0].to, 60);
9887 assert!(engine
9888 .storage()
9889 .get_deferred_graph("https://budget.example", "tm_test", &root)
9890 .await
9891 .unwrap()
9892 .is_none());
9893 for output in remote.outputs.iter() {
9894 assert!(engine
9895 .storage
9896 .find_output(&output.txid, 0, Some("tm_test"), None, true)
9897 .await
9898 .unwrap()
9899 .is_some());
9900 }
9901 }
9902
9903 struct ExpiredRoots(MockTopicManager);
9905
9906 #[async_trait(?Send)]
9907 impl TopicManagerTrait for ExpiredRoots {
9908 async fn identify_admissible_outputs(
9909 &self,
9910 tx: &Transaction,
9911 previous_coins: &[u8],
9912 off_chain_values: Option<&[u8]>,
9913 mode: SubmitMode,
9914 context: &TopicAdmittanceContext,
9915 ) -> Result<AdmittanceInstructions, TopicManagerError> {
9916 self.0
9917 .identify_admissible_outputs(tx, previous_coins, off_chain_values, mode, context)
9918 .await
9919 }
9920 async fn get_documentation(&self) -> String {
9921 self.0.get_documentation().await
9922 }
9923 async fn get_metadata(&self) -> ServiceMetadata {
9924 self.0.get_metadata().await
9925 }
9926 fn sync_expiry(&self, _: &bsv_rs::script::LockingScript) -> Option<u64> {
9927 Some(1)
9928 }
9929 }
9930
9931 fn dead_page_engine(count: u32) -> (Engine, BudgetPageRemote, std::rc::Rc<MemoryStorage>) {
9934 let (mut engine, mut remote) = budget_page_engine(count);
9935 let gap = remote.outputs[0].txid.clone();
9936 std::rc::Rc::make_mut(&mut remote.nodes).remove(&gap);
9937 engine.managers.insert(
9938 "tm_test".to_string(),
9939 Box::new(ExpiredRoots(MockTopicManager::admitting(vec![0]))),
9940 );
9941 let store = std::rc::Rc::new(MemoryStorage::new());
9942 engine.storage = Box::new(store.clone());
9943 engine.set_graph_budget(
9944 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9945 100,
9946 60_000,
9947 );
9948 (engine, remote, store)
9949 }
9950
9951 #[tokio::test]
9955 async fn cursor_018_a_sync_that_only_refuses_the_dead_is_a_yield() {
9956 let (mut engine, mut remote, store) = dead_page_engine(3);
9957 remote.node_cost_ms = 0;
9958 virtual_peer_budget(&mut engine, &remote);
9959 engine.set_gasp_remote_factory(Box::new(remote));
9960 let result = engine.start_gasp_sync().await.unwrap();
9961 let topic = &result.topics_synced["tm_test"];
9962 assert_eq!(topic.expired_refused, 2);
9963 assert_eq!(topic.finalized_graphs, 0);
9964 assert!(topic.cursor_moves.is_empty());
9965 let host = crate::gasp::peer_origin("https://budget.example");
9966 assert_eq!(store.peer_yieldless_syncs(&host, "tm_test"), 0);
9967 let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9968 assert_eq!(health.consecutive_failures, 0);
9969 }
9970
9971 #[tokio::test]
9977 async fn cursor_018_deadline_drops_in_a_row_do_not_quarantine_a_serving_peer() {
9978 let (mut engine, remote, store) = dead_page_engine(200);
9979 virtual_peer_budget(&mut engine, &remote);
9980 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9981 let host = crate::gasp::peer_origin("https://budget.example");
9982 let runs = crate::gasp::PEER_QUARANTINE_THRESHOLD + 1;
9983 let gap = format!("{}.0", remote.outputs[0].txid);
9984 for run in 1..=runs {
9985 if let Some(mut record) = store
9989 .get_deferred_graph("https://budget.example", "tm_test", &gap)
9990 .await
9991 .unwrap()
9992 .filter(|r| r.reason.starts_with("retry:fault:"))
9993 {
9994 record.retry_at = 0;
9995 store.put_deferred_graph(&record).await.unwrap();
9996 }
9997 let asked = remote.asked.borrow().len();
9998 let result = engine.start_gasp_sync().await.unwrap();
9999 let topic = &result.topics_synced["tm_test"];
10000 assert!(remote.asked.borrow().len() > asked, "run {run}: attempted");
10001 assert_eq!(topic.deadline_dropped_graphs, 1, "run {run}");
10002 assert!(topic.expired_refused > 0, "run {run}: {topic:?}");
10003 if run < u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
10007 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10008 } else if run == u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
10009 assert_eq!(topic.unanchorable_graphs, 1, "run {run}: {topic:?}");
10010 assert!(!topic.cursor_moves.is_empty(), "run {run}: passed");
10011 }
10012 let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
10013 assert_eq!(health.consecutive_failures, 0, "run {run}");
10014 }
10015 let asked = remote.asked.borrow();
10019 let mut dead: Vec<&String> = asked
10020 .iter()
10021 .filter(|t| **t != remote.outputs[0].txid)
10022 .collect();
10023 let fetched = dead.len();
10024 dead.sort();
10025 dead.dedup();
10026 assert_eq!(
10027 fetched - dead.len(),
10028 (runs - 1) as usize,
10029 "one per resumed cut"
10030 );
10031 assert_eq!(store.expired_outpoints("tm_test").len(), dead.len() - 1);
10032 }
10033
10034 struct GatedCourier {
10037 txid: String,
10038 raw_tx: String,
10039 proof: String,
10040 serve: std::rc::Rc<Cell<bool>>,
10041 asked: std::rc::Rc<Cell<u64>>,
10042 }
10043
10044 #[async_trait(?Send)]
10045 impl AncestorFetcher for GatedCourier {
10046 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10047 self.asked.set(self.asked.get() + 1);
10048 if self.serve.get() && txid == self.txid {
10049 Ok(FetchedAncestor {
10050 raw_tx: self.raw_tx.clone(),
10051 proof: Some(self.proof.clone()),
10052 })
10053 } else {
10054 Err(GASPError::RemoteError(format!(
10055 "no rung served the raw of {txid}"
10056 )))
10057 }
10058 }
10059 async fn verified_proof_for(&self, _: &str) -> Option<String> {
10060 None
10061 }
10062 }
10063
10064 #[tokio::test]
10068 async fn load_0112_definitive_absence_strikes_a_resumed_obligation() {
10069 let mut fixture = obligation_fixture(1, 1, 0).await;
10070 fixture
10071 .engine
10072 .set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10073 calls: std::rc::Rc::new(Cell::new(0)),
10074 bounded: false,
10075 }));
10076 let host = "https://budget.example";
10080 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10081 for run in 1..=runs {
10082 let result = fixture.engine.start_gasp_sync().await.unwrap();
10083 let topic = &result.topics_synced["tm_test"];
10084 let record = fixture
10085 .store
10086 .get_deferred_graph(host, "tm_test", &fixture.obligations[0])
10087 .await
10088 .unwrap()
10089 .unwrap();
10090 assert_eq!(record.faults, run);
10091 assert!(record.cursor_passable);
10092 if run == 1 {
10093 assert_eq!(topic.finalized_graphs, 1);
10094 }
10095 if run < runs {
10096 assert_eq!(topic.unanchorable_graphs, 0);
10097 assert_eq!(record.reason, "retry:fault:not_found");
10098 let mut record = record;
10099 record.retry_at = 0;
10100 fixture.store.put_deferred_graph(&record).await.unwrap();
10101 } else {
10102 assert_eq!(topic.unanchorable_graphs, 1);
10103 assert_eq!(record.reason, "unanchorable:not_found");
10104 }
10105 }
10106 }
10107
10108 #[tokio::test]
10109 async fn load_0112_root_time_is_clamped_and_named() {
10110 let (mut engine, mut remote) = budget_page_engine(1);
10111 remote.node_cost_ms = 16_000;
10112 let elapsed = remote.elapsed.clone();
10113 let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10114 let clock = elapsed.clone();
10115 let due = clock.get() + ms;
10116 Box::pin(std::future::poll_fn(move |_| {
10117 if clock.get() >= due {
10118 std::task::Poll::Ready(())
10119 } else {
10120 std::task::Poll::Pending
10121 }
10122 }))
10123 });
10124 engine.set_peer_sync_budget(sleep.clone(), 30_000);
10125 engine.set_graph_budget(sleep, 100, 60_000);
10126 engine.set_gasp_remote_factory(Box::new(remote));
10127 let result = engine.start_gasp_sync().await.unwrap();
10128 let topic = &result.topics_synced["tm_test"];
10129 assert_eq!(topic.finalized_graphs, 0);
10130 assert_eq!(topic.root_bounds[0].reason, "time");
10131 assert!(!topic.cursor_held_by.is_empty() || topic.deferred_graphs > 0);
10132 }
10133
10134 #[tokio::test]
10135 async fn load_0112_storage_counts_are_attempts_at_the_boundary() {
10136 let engine = make_engine(vec![0]);
10137 let meter = crate::meter::LoadMeter::default();
10138 meter
10139 .scope(async {
10140 assert_eq!(
10141 engine
10142 .storage
10143 .get_last_interaction("peer", "topic")
10144 .await
10145 .unwrap(),
10146 0
10147 );
10148 engine
10149 .storage
10150 .update_last_interaction("peer", "topic", 12)
10151 .await
10152 .unwrap();
10153 assert_eq!(
10154 engine
10155 .storage
10156 .get_last_interaction("peer", "topic")
10157 .await
10158 .unwrap(),
10159 12
10160 );
10161 })
10162 .await;
10163 assert_eq!(
10164 (
10165 meter.snapshot().storage_reads,
10166 meter.snapshot().storage_writes
10167 ),
10168 (2, 1)
10169 );
10170 engine
10171 .storage
10172 .get_last_interaction("peer", "topic")
10173 .await
10174 .unwrap();
10175 assert_eq!(meter.snapshot().storage_reads, 2);
10176 }
10177
10178 struct LoadCourier {
10179 calls: std::rc::Rc<Cell<u64>>,
10180 bounded: bool,
10181 }
10182 #[async_trait(?Send)]
10183 impl crate::gasp::AncestorFetcher for LoadCourier {
10184 async fn fetch_ancestor(
10185 &self,
10186 txid: &str,
10187 ) -> Result<crate::gasp::FetchedAncestor, crate::gasp::GASPError> {
10188 self.calls.set(self.calls.get() + 1);
10189 if self.bounded {
10190 for _ in 0..13 {
10191 crate::meter::courier_request()
10192 .map_err(|e| crate::gasp::GASPError::Other(e.into()))?;
10193 }
10194 Err(crate::gasp::GASPError::RemoteError(
10195 "still missing a proof".into(),
10196 ))
10197 } else {
10198 for _ in 0..3 {
10199 crate::meter::courier_request().unwrap();
10200 }
10201 Err(crate::gasp::GASPError::DefinitiveNotFound(txid.into()))
10202 }
10203 }
10204 async fn verified_proof_for(&self, _: &str) -> Option<String> {
10205 None
10206 }
10207 }
10208
10209 #[tokio::test]
10210 async fn load_0112_definitive_absence_is_shared_across_peers_and_topics() {
10211 let UnanchorableFixture {
10212 mut engine,
10213 remote,
10214 root,
10215 ..
10216 } = unanchorable_fixture();
10217 let calls = std::rc::Rc::new(Cell::new(0));
10218 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10219 calls: calls.clone(),
10220 bounded: false,
10221 }));
10222 engine.managers.insert(
10223 "tm_other".into(),
10224 Box::new(MockTopicManager::admitting(vec![0])),
10225 );
10226 for topic in ["tm_test", "tm_other"] {
10227 engine.config.sync_configuration.insert(
10228 topic.into(),
10229 SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
10230 );
10231 }
10232 let result = engine.start_gasp_sync().await.unwrap();
10233 assert_eq!(calls.get(), 1, "one ladder per missing ancestor per run");
10234 assert_eq!(result.cost.courier_not_found_memo_hits, 3);
10235 assert_eq!(result.cost.courier_rungs, 3);
10236 for (topic, res) in &result.topics_synced {
10237 assert_eq!(res.unanchorable_graphs, 0, "{res:?}");
10240 assert_eq!(
10241 res.cost.courier_rungs + res.cost.peer_requests,
10242 res.cost.calls_out
10243 );
10244 for host in ["https://a.example", "https://b.example"] {
10245 let mut record = engine
10246 .storage
10247 .get_deferred_graph(host, topic, &root)
10248 .await
10249 .unwrap()
10250 .unwrap();
10251 assert_eq!(record.reason, "retry:fault:not_found");
10252 assert_eq!(record.faults, 1);
10253 assert!(!record.cursor_passable);
10254 record.retry_at = 0;
10255 engine.storage.put_deferred_graph(&record).await.unwrap();
10256 }
10257 }
10258 assert!(!remote.asked.borrow().is_empty());
10259 engine.start_gasp_sync().await.unwrap();
10260 assert_eq!(calls.get(), 2, "another run can retry absence");
10261 }
10262
10263 #[tokio::test]
10264 async fn load_0112_courier_bound_defers_the_root_and_names_it() {
10265 let UnanchorableFixture {
10266 mut engine, root, ..
10267 } = unanchorable_fixture();
10268 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10269 calls: std::rc::Rc::new(Cell::new(0)),
10270 bounded: true,
10271 }));
10272 let result = engine.start_gasp_sync().await.unwrap();
10273 let record = engine
10274 .storage
10275 .get_deferred_graph("https://budget.example", "tm_test", &root)
10276 .await
10277 .unwrap()
10278 .expect("bounded root must be retained");
10279 assert_eq!(record.reason, "courier_rungs");
10280 assert!(!record.nodes.is_empty());
10281 assert_eq!(result.cost.courier_rungs, 12);
10282 assert_eq!(
10283 result.topics_synced["tm_test"].root_bounds[0].reason,
10284 "courier_rungs"
10285 );
10286 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 2);
10287 }
10288
10289 #[tokio::test]
10290 async fn load_0112_refused_save_does_not_restart_a_bounded_root() {
10291 let UnanchorableFixture {
10292 mut engine,
10293 store,
10294 root,
10295 ..
10296 } = unanchorable_fixture();
10297 store.set_deferred_graph_ceiling(Some(0));
10298 let calls = std::rc::Rc::new(Cell::new(0));
10299 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10300 calls: calls.clone(),
10301 bounded: true,
10302 }));
10303 let result = engine.start_gasp_sync().await.unwrap();
10304 let topic = &result.topics_synced["tm_test"];
10305 assert_eq!(calls.get(), 1, "no retry after a hard root cut");
10306 assert_eq!(topic.root_bounds.len(), 1);
10307 assert_eq!(result.cost.courier_rungs, 12);
10308 assert!(topic.cursor_held_by.contains(&root));
10309 assert_eq!(
10310 topic.strikes_refused, 0,
10311 "a budget cut is not a fault strike"
10312 );
10313 assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10314 assert_eq!(topic.finalized_graphs, 2);
10315 }
10316
10317 #[tokio::test]
10318 async fn load_0112_refused_save_does_not_extend_root_time() {
10319 let (mut engine, mut remote) = budget_page_engine(1);
10320 remote.node_cost_ms = 16_000;
10321 let elapsed = remote.elapsed.clone();
10322 let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10323 let clock = elapsed.clone();
10324 let due = clock.get() + ms;
10325 Box::pin(std::future::poll_fn(move |_| {
10326 if clock.get() >= due {
10327 std::task::Poll::Ready(())
10328 } else {
10329 std::task::Poll::Pending
10330 }
10331 }))
10332 });
10333 let store = std::rc::Rc::new(MemoryStorage::new());
10334 store.set_deferred_graph_ceiling(Some(0));
10335 engine.storage = Box::new(store);
10336 engine.set_peer_sync_budget(sleep.clone(), 30_000);
10337 engine.set_graph_budget(sleep, 100, 60_000);
10338 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10339 let result = engine.start_gasp_sync().await.unwrap();
10340 let topic = &result.topics_synced["tm_test"];
10341 assert_eq!(
10342 remote.asked.borrow().len(),
10343 1,
10344 "no request after the time cut"
10345 );
10346 assert_eq!(topic.root_bounds.len(), 1);
10347 assert_eq!(topic.root_bounds[0].reason, "time");
10348 assert_eq!(topic.finalized_graphs, 0);
10349 assert!(!topic.cursor_held_by.is_empty());
10350 }
10351
10352 #[tokio::test]
10353 async fn load_0112_call_limit_fallback_keeps_the_root_deadline() {
10354 let UnanchorableFixture {
10355 mut engine,
10356 mut remote,
10357 store,
10358 root,
10359 serve,
10360 ..
10361 } = unanchorable_fixture();
10362 store.set_deferred_graph_ceiling(Some(0));
10363 serve.set(true);
10364 remote.node_cost_ms = 8_000;
10365 struct DelayedCourier {
10366 inner: std::rc::Rc<dyn AncestorFetcher>,
10367 clock: std::rc::Rc<Cell<u64>>,
10368 }
10369 #[async_trait(?Send)]
10370 impl AncestorFetcher for DelayedCourier {
10371 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10372 self.clock.set(self.clock.get() + 8_000);
10373 tokio::task::yield_now().await;
10374 self.inner.fetch_ancestor(txid).await
10375 }
10376 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
10377 self.inner.verified_proof_for(txid).await
10378 }
10379 }
10380 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(DelayedCourier {
10381 inner: engine.gasp_ancestor_fetcher.as_ref().unwrap().clone(),
10382 clock: remote.elapsed.clone(),
10383 }));
10384 let elapsed = remote.elapsed.clone();
10385 let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10386 let clock = elapsed.clone();
10387 let due = clock.get() + ms;
10388 Box::pin(std::future::poll_fn(move |_| {
10389 if clock.get() >= due {
10390 std::task::Poll::Ready(())
10391 } else {
10392 std::task::Poll::Pending
10393 }
10394 }))
10395 });
10396 engine.set_peer_sync_budget(sleep.clone(), 30_000);
10397 engine.set_graph_budget(sleep, 1, 60_000);
10398 engine.set_gasp_remote_factory(Box::new(remote));
10399 let result = engine.start_gasp_sync().await.unwrap();
10400 let topic = &result.topics_synced["tm_test"];
10401 assert!(
10402 topic
10403 .root_bounds
10404 .iter()
10405 .any(|b| b.outpoint == root && b.reason == "time"),
10406 "{topic:?}"
10407 );
10408 assert!(topic.cursor_held_by.contains(&root));
10409 assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10410 }
10411
10412 #[tokio::test]
10413 async fn load_0112_cost_matches_the_remote_call_log() {
10414 let (mut engine, remote) = budget_page_engine(2);
10415 remote.elapsed.set(0);
10416 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10417 let result = engine.start_gasp_sync().await.unwrap();
10418 let actual_calls = (remote.requests.borrow().len() + remote.asked.borrow().len()) as u64;
10419 assert_eq!(result.cost.peer_requests, actual_calls);
10420 assert_eq!(result.cost.calls_out, actual_calls);
10421 assert!(result.cost.storage_reads > 0);
10422 assert!(result.cost.storage_writes > 0);
10423 assert_eq!(
10424 result.cost.storage_reads,
10425 result.topics_synced["tm_test"].cost.storage_reads
10426 );
10427 assert_eq!(
10428 result.cost.storage_writes,
10429 result.topics_synced["tm_test"].cost.storage_writes
10430 );
10431 }
10432
10433 struct UnanchorableFixture {
10434 engine: Engine,
10435 remote: BudgetPageRemote,
10436 store: std::rc::Rc<MemoryStorage>,
10437 root: String,
10438 serve: std::rc::Rc<Cell<bool>>,
10439 asked: std::rc::Rc<Cell<u64>>,
10440 }
10441
10442 fn unanchorable_fixture() -> UnanchorableFixture {
10443 let (mut engine, mut remote) = budget_page_engine(3);
10444 remote.node_cost_ms = 0;
10445 let parent = child_of(&format!("{:064x}", 0xab), 0);
10446 let child = child_of(&parent.id(), 0);
10447 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
10448 for n in 1..=3u32 {
10449 tracker.add_root(n, child_of(&format!("{n:064x}"), 0).id());
10450 }
10451 tracker.add_root(900, parent.id());
10452 engine.chain_tracker = Some(Box::new(tracker));
10453 let replaced = std::mem::replace(
10454 &mut std::rc::Rc::make_mut(&mut remote.outputs)[0].txid,
10455 child.id(),
10456 );
10457 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10458 nodes.remove(&replaced);
10459 nodes.insert(
10460 child.id(),
10461 crate::types::GASPNode {
10462 graph_id: String::new(),
10463 raw_tx: child.to_hex(),
10464 output_index: 0,
10465 proof: None,
10466 tx_metadata: None,
10467 output_metadata: None,
10468 inputs: None,
10469 },
10470 );
10471 remote.faults_for.borrow_mut().insert(parent.id());
10476 let serve = std::rc::Rc::new(Cell::new(false));
10477 let asked = std::rc::Rc::new(Cell::new(0));
10478 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(GatedCourier {
10479 txid: parent.id(),
10480 raw_tx: parent.to_hex(),
10481 proof: single_leaf_bump_hex(&parent.id(), 900),
10482 serve: serve.clone(),
10483 asked: asked.clone(),
10484 }));
10485 let store = std::rc::Rc::new(MemoryStorage::new());
10486 engine.storage = Box::new(store.clone());
10487 virtual_peer_budget(&mut engine, &remote);
10488 engine.set_graph_budget(
10489 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10490 100,
10491 60_000,
10492 );
10493 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10494 UnanchorableFixture {
10495 engine,
10496 remote,
10497 store,
10498 root: format!("{}.0", child.id()),
10499 serve,
10500 asked,
10501 }
10502 }
10503
10504 #[tokio::test]
10513 async fn unanchorable_019_a_root_faulted_three_runs_is_recorded_passed_and_admitted_later() {
10514 let UnanchorableFixture {
10515 engine,
10516 remote,
10517 store,
10518 root,
10519 serve,
10520 asked,
10521 } = unanchorable_fixture();
10522 let host = "https://budget.example";
10523 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10524 for run in 1..=runs {
10525 let result = engine.start_gasp_sync().await.unwrap();
10526 let topic = &result.topics_synced["tm_test"];
10527 assert_eq!(topic.faulted_roots.len(), 1, "run {run}: {topic:?}");
10528 let named = &topic.faulted_roots[0];
10529 assert_eq!(
10530 (named.peer.as_str(), named.outpoint.as_str()),
10531 (host, root.as_str())
10532 );
10533 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10534 assert_eq!(named.faults, run, "run {run}");
10535 assert!(named.error.contains("no rung served"), "{named:?}");
10536 let record = engine
10537 .storage()
10538 .get_deferred_graph(host, "tm_test", &root)
10539 .await
10540 .unwrap()
10541 .unwrap();
10542 assert_eq!(record.faults, run);
10543 assert!(record.nodes.is_empty(), "the root alone, asked again");
10544 if run < runs {
10545 assert!(!named.unanchorable);
10546 assert_eq!(topic.unanchorable_graphs, 0);
10547 assert_eq!(record.reason, "retry:fault:courier");
10548 assert!(!record.cursor_passable);
10549 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10550 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10551 } else {
10552 assert!(named.unanchorable);
10553 assert_eq!(topic.unanchorable_graphs, 1);
10554 assert_eq!(record.reason, "unanchorable:courier");
10555 assert!(record.cursor_passable);
10556 assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
10557 assert_eq!(topic.cursor_moves.len(), 1);
10558 assert_eq!(
10559 topic.cursor_moves[0].to, 30,
10560 "the page's last finished score"
10561 );
10562 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10563 }
10564 if run == 1 {
10565 assert_eq!(topic.finalized_graphs, 2, "20 and 30");
10566 }
10567 }
10568
10569 let (courier_asks, node_asks) = (asked.get(), remote.asked.borrow().len());
10572 let streak = store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test");
10573 let result = engine.start_gasp_sync().await.unwrap();
10574 let topic = &result.topics_synced["tm_test"];
10575 assert!(topic.faulted_roots.is_empty(), "{topic:?}");
10576 assert!(topic.cursor_held_by.is_empty());
10577 assert_eq!(topic.unanchorable_graphs, 0);
10578 assert_eq!(asked.get(), courier_asks);
10579 assert_eq!(remote.asked.borrow().len(), node_asks);
10580 assert_eq!(
10581 store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test"),
10582 streak
10583 );
10584
10585 let mut record = engine
10588 .storage()
10589 .get_deferred_graph(host, "tm_test", &root)
10590 .await
10591 .unwrap()
10592 .unwrap();
10593 record.retry_at = 1;
10594 engine.storage().put_deferred_graph(&record).await.unwrap();
10595 serve.set(true);
10596 let result = engine.start_gasp_sync().await.unwrap();
10597 let topic = &result.topics_synced["tm_test"];
10598 assert_eq!(topic.finalized_graphs, 1, "{topic:?}");
10599 assert_eq!(topic.unanchorable_cleared, 1);
10600 assert!(topic.faulted_roots.is_empty());
10601 assert!(engine
10602 .storage()
10603 .get_deferred_graph(host, "tm_test", &root)
10604 .await
10605 .unwrap()
10606 .is_none());
10607 let (txid, _) = crate::gasp::parse_outpoint(&root).unwrap();
10608 assert!(engine
10609 .storage
10610 .find_output(&txid, 0, Some("tm_test"), None, true)
10611 .await
10612 .unwrap()
10613 .is_some());
10614 }
10615
10616 #[tokio::test]
10621 async fn unanchorable_019_only_the_lowest_faulted_root_is_recorded_and_all_are_named() {
10622 let (mut engine, mut remote) = budget_page_engine(4);
10623 remote.node_cost_ms = 0;
10624 for idx in [1usize, 3] {
10632 let parent = child_of(&format!("{:064x}", 0xdead_0000 + idx), 0);
10633 let child = child_of(&parent.id(), 0);
10634 let old = std::mem::replace(
10635 &mut std::rc::Rc::make_mut(&mut remote.outputs)[idx].txid,
10636 child.id(),
10637 );
10638 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10639 nodes.remove(&old);
10640 nodes.insert(
10641 child.id(),
10642 crate::types::GASPNode {
10643 graph_id: String::new(),
10644 raw_tx: child.to_hex(),
10645 output_index: 0,
10646 proof: None,
10647 tx_metadata: None,
10648 output_metadata: None,
10649 inputs: None,
10650 },
10651 );
10652 remote.faults_for.borrow_mut().insert(parent.id());
10653 }
10654 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
10655 asked: std::rc::Rc::default(),
10656 transient: std::rc::Rc::default(),
10657 }));
10658 let store = std::rc::Rc::new(MemoryStorage::new());
10659 engine.storage = Box::new(store.clone());
10660 virtual_peer_budget(&mut engine, &remote);
10661 engine.set_graph_budget(
10662 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10663 100,
10664 60_000,
10665 );
10666 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10667 let result = engine.start_gasp_sync().await.unwrap();
10668 let topic = &result.topics_synced["tm_test"];
10669 let named: Vec<(&str, &str, u32)> = topic
10670 .faulted_roots
10671 .iter()
10672 .map(|f| (f.outpoint.as_str(), f.class.as_str(), f.faults))
10673 .collect();
10674 let low = format!("{}.0", remote.outputs[1].txid);
10675 let high = format!("{}.0", remote.outputs[3].txid);
10676 assert_eq!(
10677 named,
10678 vec![
10679 (low.as_str(), crate::gasp::fault_class::COURIER, 1),
10680 (high.as_str(), crate::gasp::fault_class::COURIER, 0),
10681 ]
10682 );
10683 assert_eq!(topic.cursor_moves[0].to, 10, "below the lower gap");
10684 let host = "https://budget.example";
10685 assert!(store
10686 .get_deferred_graph(host, "tm_test", &high)
10687 .await
10688 .unwrap()
10689 .is_none());
10690 assert_eq!(
10691 store
10692 .get_deferred_graph(host, "tm_test", &low)
10693 .await
10694 .unwrap()
10695 .unwrap()
10696 .reason,
10697 "retry:fault:courier"
10698 );
10699 }
10700
10701 fn bare_record(n: u32, score: u64, reason: &str, passable: bool) -> crate::gasp::DeferredGraph {
10704 let outpoint = format!("{:064x}.0", 0xdead_0000 + n);
10705 crate::gasp::DeferredGraph {
10706 peer: "https://budget.example".into(),
10707 topic: "tm_test".into(),
10708 outpoint: outpoint.clone(),
10709 score,
10710 nodes: Vec::new(),
10711 pending: vec![crate::gasp::PendingInput {
10712 outpoint: outpoint.clone(),
10713 graph_id: outpoint,
10714 metadata: true,
10715 spent_by: None,
10716 parent_proven: false,
10717 }],
10718 calls: 0,
10719 passes: 0,
10720 reason: reason.into(),
10721 configured: true,
10722 idle_faults: 0,
10723 cursor_passable: passable,
10724 faults: 0,
10725 retry_at: 0,
10726 idle_resumes: 0,
10727 unanchorable_retries: 0,
10728 expiry: None,
10729 }
10730 }
10731
10732 async fn hold_obligations(engine: &Engine, count: usize) -> Vec<String> {
10736 let mut roots = Vec::new();
10737 for n in 0..count {
10738 let record = bare_record(n as u32, 1 + n as u64, "retry", true);
10739 roots.push(record.outpoint.clone());
10740 engine.storage().put_deferred_graph(&record).await.unwrap();
10741 }
10742 roots
10743 }
10744
10745 #[tokio::test]
10753 async fn room_0110_a_full_store_still_records_the_cursors_holder_and_passes_it() {
10754 let UnanchorableFixture {
10755 engine,
10756 store,
10757 root,
10758 ..
10759 } = unanchorable_fixture();
10760 let host = "https://budget.example";
10761 let obligations =
10762 hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10763 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10764 for run in 1..=runs {
10765 let result = engine.start_gasp_sync().await.unwrap();
10766 let topic = &result.topics_synced["tm_test"];
10767 assert_eq!(topic.strikes_refused, 0, "run {run}: {topic:?}");
10768 let named = topic
10769 .faulted_roots
10770 .iter()
10771 .find(|f| f.outpoint == root)
10772 .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
10773 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10774 assert_eq!(named.faults, run, "run {run}: the strike was written");
10775 assert!(named.refused.is_empty(), "{named:?}");
10776 if run < runs {
10777 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10778 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10779 } else {
10780 assert!(named.unanchorable);
10781 assert_eq!(topic.unanchorable_graphs, 1);
10782 assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10783 assert_eq!(topic.cursor_moves[0].to, 30, "passed");
10784 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10785 }
10786 }
10787 let held: Vec<String> = store
10789 .deferred_graphs()
10790 .into_iter()
10791 .map(|r| r.outpoint)
10792 .collect();
10793 assert_eq!(held.len(), obligations.len(), "{held:?}");
10795 assert_eq!(store.fault_memos().len(), 1);
10796 assert!(obligations.iter().all(|o| held.contains(o)));
10797 let record = store
10798 .get_deferred_graph(host, "tm_test", &root)
10799 .await
10800 .unwrap()
10801 .unwrap();
10802 assert_eq!(record.reason, "unanchorable:courier");
10803 }
10804
10805 #[tokio::test]
10812 async fn room_0110_a_refused_strike_is_counted_and_named() {
10813 let UnanchorableFixture {
10814 engine,
10815 store,
10816 root,
10817 ..
10818 } = unanchorable_fixture();
10819 store.set_fault_memo_ceiling(Some(0));
10821 let refused_as = |result: &super::GASPSyncResult, why: &str| {
10822 let topic = &result.topics_synced["tm_test"];
10823 assert_eq!(topic.strikes_refused, 1, "{topic:?}");
10824 let named = topic
10825 .faulted_roots
10826 .iter()
10827 .find(|f| f.outpoint == root)
10828 .unwrap_or_else(|| panic!("{topic:?}"));
10829 assert_eq!(named.refused, why, "{named:?}");
10830 assert_eq!(named.faults, 0, "no record holds the count");
10831 assert!(topic.cursor_moves.is_empty(), "the gap holds");
10832 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10833 };
10834 let result = engine.start_gasp_sync().await.unwrap();
10835 refused_as(&result, crate::gasp::strike_refused::NO_ROOM);
10836 assert!(store
10837 .get_deferred_graph("https://budget.example", "tm_test", &root)
10838 .await
10839 .unwrap()
10840 .is_none());
10841
10842 store.set_fault_memo_ceiling(None);
10844 store.set_deferred_graph_put_fault(true);
10845 let result = engine.start_gasp_sync().await.unwrap();
10846 refused_as(&result, crate::gasp::strike_refused::STORE_FAULT);
10847
10848 store.set_deferred_graph_put_fault(false);
10850 let result = engine.start_gasp_sync().await.unwrap();
10851 let topic = &result.topics_synced["tm_test"];
10852 assert_eq!(topic.strikes_refused, 0, "{topic:?}");
10853 assert!(store
10854 .get_deferred_graph("https://budget.example", "tm_test", &root)
10855 .await
10856 .unwrap()
10857 .is_some());
10858 }
10859
10860 #[tokio::test]
10867 async fn room_0110_idle_obligations_back_off_and_the_page_is_walked() {
10868 let (mut engine, mut remote) = budget_page_engine(3);
10869 remote.node_cost_ms = 2000;
10870 let store = std::rc::Rc::new(MemoryStorage::new());
10871 engine.storage = Box::new(store.clone());
10872 virtual_peer_budget(&mut engine, &remote);
10873 engine.set_graph_budget(
10874 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10875 100,
10876 60_000,
10877 );
10878 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10879 let obligations =
10880 hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10881 let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10882 for run in 1..=idle {
10886 let result = engine.start_gasp_sync().await.unwrap();
10887 let topic = &result.topics_synced["tm_test"];
10888 assert_eq!(topic.finalized_graphs, 0, "run {run}: {topic:?}");
10889 assert!(topic.cursor_moves.is_empty(), "run {run}");
10890 assert_eq!(topic.resumed_graphs, 15, "run {run}");
10891 assert_eq!(topic.waiting_graphs, 0, "run {run}");
10892 let expect = if run == idle { 15 } else { 0 };
10893 assert_eq!(topic.backed_off_graphs, expect, "run {run}: {topic:?}");
10894 }
10895 let result = engine.start_gasp_sync().await.unwrap();
10897 let topic = &result.topics_synced["tm_test"];
10898 assert_eq!(topic.waiting_graphs, 15, "{topic:?}");
10899 assert_eq!(topic.finalized_graphs, 3, "the page's fresh roots");
10900 assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10901 assert_eq!(topic.cursor_moves[0].to, 30);
10902 let mut backed_off = 15;
10904 for _ in 0..idle {
10905 let result = engine.start_gasp_sync().await.unwrap();
10906 backed_off += result.topics_synced["tm_test"].backed_off_graphs;
10907 }
10908 assert_eq!(backed_off, obligations.len() as u64);
10909 let asked = remote.asked.borrow().len();
10910 let result = engine.start_gasp_sync().await.unwrap();
10911 let topic = &result.topics_synced["tm_test"];
10912 assert_eq!(topic.waiting_graphs, obligations.len() as u64, "{topic:?}");
10913 assert_eq!(topic.resumed_graphs, 0);
10914 assert!(topic.dropped_graphs.is_empty(), "{topic:?}");
10915 assert_eq!(remote.asked.borrow().len(), asked, "no request");
10916 let now = (current_timestamp_ms() / 1000.0) as u64;
10918 let held = store.deferred_graphs();
10919 assert_eq!(held.len(), obligations.len());
10920 for record in &held {
10921 assert!(obligations.contains(&record.outpoint));
10922 assert!(record.cursor_passable);
10923 assert!(record.idle_resumes >= idle, "{record:?}");
10924 assert!(record.retry_at > now, "{record:?}");
10925 }
10926 }
10927
10928 #[tokio::test]
10936 async fn room_0110_a_waiting_record_whose_root_expired_is_swept() {
10937 let (mut engine, remote) = budget_page_engine(1);
10938 let store = std::rc::Rc::new(MemoryStorage::new());
10939 engine.storage = Box::new(store.clone());
10940 engine.set_graph_budget(
10941 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10942 100,
10943 60_000,
10944 );
10945 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10946 let now = (current_timestamp_ms() / 1000.0) as u64;
10947 let mut waiting = bare_record(0, 1, "retry", true);
10948 waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10949 waiting.retry_at = now + 3600;
10950 let dead = bare_record(1, 2, "retry", true);
10951 let mut dead_waiting = dead.clone();
10952 dead_waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10953 dead_waiting.retry_at = now + 3600;
10954 for record in [&waiting, &dead_waiting] {
10955 engine.storage().put_deferred_graph(record).await.unwrap();
10956 }
10957 engine
10958 .storage()
10959 .remember_expired_outpoint("tm_test", &dead.outpoint, 1)
10960 .await
10961 .unwrap();
10962 let result = engine.start_gasp_sync().await.unwrap();
10963 let topic = &result.topics_synced["tm_test"];
10964 assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
10965 assert_eq!(
10966 topic
10967 .dropped_graphs
10968 .iter()
10969 .map(|d| (d.outpoint.as_str(), d.reason.as_str()))
10970 .collect::<Vec<_>>(),
10971 vec![(dead.outpoint.as_str(), "refused")]
10972 );
10973 let held: Vec<String> = store
10974 .deferred_graphs()
10975 .into_iter()
10976 .map(|r| r.outpoint)
10977 .collect();
10978 assert_eq!(held, vec![waiting.outpoint.clone()]);
10979 let asked = remote.asked.borrow();
10980 assert!(!asked.iter().any(|t| dead.outpoint.starts_with(t.as_str())));
10981 assert!(!asked
10982 .iter()
10983 .any(|t| waiting.outpoint.starts_with(t.as_str())));
10984 }
10985
10986 #[tokio::test]
10989 async fn room_0110_a_resume_that_appends_a_node_resets_the_count() {
10990 let UnanchorableFixture {
10991 mut engine,
10992 remote,
10993 store,
10994 root,
10995 ..
10996 } = unanchorable_fixture();
10997 engine.set_graph_budget_limbs(u64::MAX, 1);
10999 engine.set_gasp_remote_factory(Box::new(remote));
11000 let mut record = bare_record(0, 10, "retry", true);
11001 record.outpoint.clone_from(&root);
11002 record.pending[0].outpoint.clone_from(&root);
11003 record.pending[0].graph_id.clone_from(&root);
11004 record.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF - 1;
11005 engine.storage().put_deferred_graph(&record).await.unwrap();
11006 let result = engine.start_gasp_sync().await.unwrap();
11007 let topic = &result.topics_synced["tm_test"];
11008 assert_eq!(topic.resumed_graphs, 1, "{topic:?}");
11009 assert_eq!(topic.backed_off_graphs, 0);
11010 let held = store
11011 .get_deferred_graph("https://budget.example", "tm_test", &root)
11012 .await
11013 .unwrap()
11014 .unwrap();
11015 assert_eq!(held.nodes.len(), 1, "{held:?}");
11016 assert_eq!(held.reason, "nodes");
11017 assert_eq!(held.idle_resumes, 0);
11018 assert_eq!(held.retry_at, 0);
11019 }
11020
11021 struct ExhaustedCourier {
11025 asked: std::rc::Rc<Cell<u64>>,
11026 transient: std::rc::Rc<Cell<bool>>,
11028 }
11029
11030 #[async_trait(?Send)]
11031 impl AncestorFetcher for ExhaustedCourier {
11032 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
11033 self.asked.set(self.asked.get() + 1);
11034 if self.transient.get() {
11035 return Err(GASPError::RemoteError(format!(
11036 "rung rate limited for {txid}"
11037 )));
11038 }
11039 Err(GASPError::NodeNotFound(format!(
11040 "no raw tx for {txid} (bitails + woc + bananablocks exhausted)"
11041 )))
11042 }
11043 async fn verified_proof_for(&self, _: &str) -> Option<String> {
11044 None
11045 }
11046 }
11047
11048 struct ObligationFixture {
11054 engine: Engine,
11055 remote: BudgetPageRemote,
11056 store: std::rc::Rc<MemoryStorage>,
11057 obligations: Vec<String>,
11058 courier: std::rc::Rc<Cell<u64>>,
11059 transient: std::rc::Rc<Cell<bool>>,
11060 }
11061
11062 async fn obligation_fixture(page: u32, count: u32, node_cost_ms: u64) -> ObligationFixture {
11063 let (mut engine, mut remote) = budget_page_engine(page);
11064 remote.node_cost_ms = node_cost_ms;
11065 let mut obligations = Vec::new();
11066 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
11067 let mut records = Vec::new();
11068 for n in 0..count {
11069 let parent = child_of(&format!("{:064x}", 0xbeef_0000 + n), 0);
11070 let child = child_of(&parent.id(), 0);
11071 remote.faults_for.borrow_mut().insert(parent.id());
11076 nodes.insert(
11077 child.id(),
11078 crate::types::GASPNode {
11079 graph_id: String::new(),
11080 raw_tx: child.to_hex(),
11081 output_index: 0,
11082 proof: None,
11083 tx_metadata: None,
11084 output_metadata: None,
11085 inputs: None,
11086 },
11087 );
11088 let mut record = bare_record(n, 1 + u64::from(n), "retry", true);
11089 let outpoint = format!("{}.0", child.id());
11090 record.outpoint.clone_from(&outpoint);
11091 record.pending[0].outpoint.clone_from(&outpoint);
11092 record.pending[0].graph_id.clone_from(&outpoint);
11093 obligations.push(outpoint);
11094 records.push(record);
11095 }
11096 let courier = std::rc::Rc::new(Cell::new(0));
11097 let transient = std::rc::Rc::new(Cell::new(false));
11098 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
11099 asked: courier.clone(),
11100 transient: transient.clone(),
11101 }));
11102 let store = std::rc::Rc::new(MemoryStorage::new());
11103 engine.storage = Box::new(store.clone());
11104 virtual_peer_budget(&mut engine, &remote);
11105 engine.set_graph_budget(
11106 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
11107 100,
11108 60_000,
11109 );
11110 engine.set_gasp_remote_factory(Box::new(remote.clone()));
11111 for record in &records {
11112 let json = serde_json::to_value(record).unwrap();
11114 assert!(json.get("idleResumes").is_none() && json.get("faults").is_none());
11115 engine.storage().put_deferred_graph(record).await.unwrap();
11116 }
11117 ObligationFixture {
11118 engine,
11119 remote,
11120 store,
11121 obligations,
11122 courier,
11123 transient,
11124 }
11125 }
11126
11127 #[tokio::test]
11136 async fn obligations_0111_a_faulted_resume_is_struck_and_the_page_is_walked() {
11137 let ObligationFixture {
11138 engine,
11139 remote,
11140 store,
11141 obligations,
11142 courier,
11143 ..
11144 } = obligation_fixture(3, 1, 0).await;
11145 let host = "https://budget.example";
11146 let held = &obligations[0];
11147 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
11148 for run in 1..=runs {
11149 let result = engine.start_gasp_sync().await.unwrap();
11150 let topic = &result.topics_synced["tm_test"];
11151 let named = topic
11152 .faulted_roots
11153 .iter()
11154 .find(|f| &f.outpoint == held)
11155 .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11156 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
11157 assert!(named.error.contains("exhausted"), "{named:?}");
11158 assert_eq!(named.faults, run, "run {run}: struck on its own slot");
11159 assert!(named.refused.is_empty(), "{named:?}");
11160 assert_eq!(topic.strikes_refused, 0);
11161 assert!(topic.cursor_held_by.is_empty(), "run {run}: {topic:?}");
11162 if run == 1 {
11163 assert_eq!(topic.finalized_graphs, 3, "the page, the same run");
11164 assert_eq!(topic.cursor_moves[0].to, 30);
11165 }
11166 let record = store
11167 .get_deferred_graph(host, "tm_test", held)
11168 .await
11169 .unwrap()
11170 .unwrap();
11171 assert_eq!(record.faults, run, "{record:?}");
11172 assert!(record.cursor_passable, "run {run}: never holds the cursor");
11173 assert!(record.nodes.is_empty());
11174 if run < runs {
11175 assert!(!named.unanchorable);
11176 assert_eq!(record.reason, "retry:fault:courier");
11177 assert_eq!(topic.unanchorable_graphs, 0);
11178 } else {
11179 assert!(named.unanchorable);
11180 assert_eq!(record.reason, "unanchorable:courier");
11181 assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
11182 assert_eq!(topic.unanchorable_graphs, 1, "{topic:?}");
11183 }
11184 }
11185 let (asked, couriered) = (remote.asked.borrow().len(), courier.get());
11188 let result = engine.start_gasp_sync().await.unwrap();
11189 let topic = &result.topics_synced["tm_test"];
11190 assert!(topic.faulted_roots.is_empty(), "{topic:?}");
11191 assert_eq!(topic.resumed_graphs, 0);
11192 assert_eq!(remote.asked.borrow().len(), asked);
11193 assert_eq!(courier.get(), couriered);
11194 assert!(store.deferred_graphs().is_empty());
11196 assert_eq!(store.fault_memos().len(), 1);
11197 }
11198
11199 #[tokio::test]
11211 async fn obligations_0111_a_resume_that_appends_nothing_counts_on_every_ending() {
11212 let host = "https://budget.example";
11213 let fixture = obligation_fixture(1, 1, 0).await;
11216 fixture.store.set_fault_memo_ceiling(Some(0));
11217 let held = &fixture.obligations[0];
11218 let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
11219 for run in 1..=idle {
11220 let result = fixture.engine.start_gasp_sync().await.unwrap();
11221 let topic = &result.topics_synced["tm_test"];
11222 let record = fixture
11223 .store
11224 .get_deferred_graph(host, "tm_test", held)
11225 .await
11226 .unwrap()
11227 .unwrap();
11228 assert_eq!(record.idle_resumes, run, "run {run}: {record:?} {topic:?}");
11229 assert_eq!(record.reason, "retry", "the drop stands");
11230 assert!(record.cursor_passable && record.nodes.is_empty());
11231 assert_eq!(topic.idle_counted, 1, "run {run}: {topic:?}");
11232 let named = topic
11233 .faulted_roots
11234 .iter()
11235 .find(|f| &f.outpoint == held)
11236 .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11237 assert_eq!(named.refused, crate::gasp::strike_refused::NO_ROOM);
11238 assert_eq!(named.faults, 0);
11239 assert_eq!(topic.strikes_refused, 1);
11240 assert_eq!(topic.backed_off_graphs, u64::from(run == idle));
11241 }
11242 let result = fixture.engine.start_gasp_sync().await.unwrap();
11243 let topic = &result.topics_synced["tm_test"];
11244 assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
11245 assert_eq!(topic.idle_counted, 0);
11246
11247 let mut fixture = obligation_fixture(1, 1, 0).await;
11249 let gone = fixture.obligations[0].clone();
11250 std::rc::Rc::make_mut(&mut fixture.remote.nodes).remove(gone.strip_suffix(".0").unwrap());
11251 fixture
11252 .engine
11253 .set_gasp_remote_factory(Box::new(fixture.remote.clone()));
11254 let result = fixture.engine.start_gasp_sync().await.unwrap();
11255 let topic = &result.topics_synced["tm_test"];
11256 assert_eq!(topic.idle_counted, 1, "{topic:?}");
11257 assert_eq!(
11258 topic
11259 .dropped_graphs
11260 .iter()
11261 .map(|d| d.reason.as_str())
11262 .collect::<Vec<_>>(),
11263 vec!["not_held"]
11264 );
11265 let record = fixture
11266 .store
11267 .get_deferred_graph(host, "tm_test", &gone)
11268 .await
11269 .unwrap()
11270 .unwrap();
11271 assert_eq!((record.idle_resumes, record.faults), (1, 0), "{record:?}");
11272
11273 let fixture = obligation_fixture(1, 1, 0).await;
11277 fixture.transient.set(true);
11278 let kept = &fixture.obligations[0];
11279 let result = fixture.engine.start_gasp_sync().await.unwrap();
11280 assert_eq!(result.topics_synced["tm_test"].idle_counted, 0, "progress");
11281 let result = fixture.engine.start_gasp_sync().await.unwrap();
11282 let topic = &result.topics_synced["tm_test"];
11283 assert_eq!(topic.idle_counted, 1, "{topic:?}");
11284 let record = fixture
11285 .store
11286 .get_deferred_graph(host, "tm_test", kept)
11287 .await
11288 .unwrap()
11289 .unwrap();
11290 assert_eq!(record.reason, "fault");
11291 assert_eq!(
11292 (record.nodes.len(), record.idle_resumes),
11293 (1, 1),
11294 "{record:?}"
11295 );
11296 }
11297
11298 #[tokio::test]
11309 async fn obligations_0111_fourteen_obligations_leave_the_page_its_share() {
11310 let ObligationFixture {
11311 mut engine,
11312 remote,
11313 store,
11314 obligations,
11315 ..
11316 } = obligation_fixture(12, 14, 1500).await;
11317 let elapsed = remote.elapsed.clone();
11318 engine.set_graph_budget(
11319 std::rc::Rc::new(move |ms| {
11320 let elapsed = elapsed.clone();
11321 let until = elapsed.get() + ms;
11322 Box::pin(std::future::poll_fn(move |_| {
11323 if elapsed.get() >= until {
11324 std::task::Poll::Ready(())
11325 } else {
11326 std::task::Poll::Pending
11327 }
11328 }))
11329 }),
11330 100,
11331 15_000,
11332 );
11333 assert_eq!(crate::gasp::resume_share_ms(30_000, 15_000), 9_900);
11334 let result = engine.start_gasp_sync().await.unwrap();
11335 let topic = &result.topics_synced["tm_test"];
11336 assert!(topic.errors.is_empty(), "inside the slice: {topic:?}");
11337 assert_eq!(topic.finalized_graphs, 12, "the page's share: {topic:?}");
11338 assert_eq!(topic.cursor_moves[0].to, 120);
11339 assert_eq!(topic.resumed_graphs, 4, "{topic:?}");
11346 assert_eq!(topic.held_back_graphs, 10, "{topic:?}");
11347 assert_eq!(topic.faulted_roots.len(), 3, "{topic:?}");
11348 assert_eq!((topic.stalled_graphs, topic.idle_counted), (1, 1));
11349 assert!(topic.cursor_held_by.is_empty());
11350 let host = "https://budget.example";
11351 let mut reasons = std::collections::BTreeMap::new();
11352 for outpoint in &obligations {
11353 let record = store
11354 .get_deferred_graph(host, "tm_test", outpoint)
11355 .await
11356 .unwrap()
11357 .unwrap();
11358 assert!(record.cursor_passable, "{record:?}");
11359 if record.reason == "retry" {
11360 assert_eq!((record.idle_resumes, record.retry_at), (0, 0));
11362 assert_eq!((record.faults, record.passes), (0, 0));
11363 }
11364 *reasons.entry(record.reason).or_insert(0) += 1;
11365 }
11366 assert_eq!(
11367 reasons,
11368 std::collections::BTreeMap::from([
11369 ("retry".to_string(), 10),
11370 ("retry:fault:courier".to_string(), 3),
11371 ("time".to_string(), 1),
11372 ])
11373 );
11374 }
11375
11376 #[tokio::test]
11377 async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
11378 let (mut engine, mut remote) = budget_page_engine(1);
11379 let txid = remote.outputs[0].txid.clone();
11380 std::rc::Rc::make_mut(&mut remote.nodes)
11381 .get_mut(&txid)
11382 .unwrap()
11383 .proof = None;
11384 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
11385 proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
11386 requested: std::rc::Rc::default(),
11387 }));
11388 virtual_peer_budget(&mut engine, &remote);
11389 engine.set_gasp_remote_factory(Box::new(remote));
11390 let result = engine.start_gasp_sync().await.unwrap();
11391 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
11392 assert!(engine
11393 .storage
11394 .find_output(&txid, 0, Some("tm_test"), None, true)
11395 .await
11396 .unwrap()
11397 .is_none());
11398 }
11399
11400 impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
11401 fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11402 Box::new(MockSyncRemote {
11403 utxos: vec![
11404 crate::types::GASPOutput {
11405 txid: "remote_tx1".to_string(),
11406 output_index: 0,
11407 score: 100.0,
11408 },
11409 crate::types::GASPOutput {
11410 txid: "remote_tx2".to_string(),
11411 output_index: 0,
11412 score: 200.0,
11413 },
11414 ],
11415 })
11416 }
11417 }
11418
11419 #[tokio::test]
11420 async fn test_start_gasp_sync_with_factory_runs_sync() {
11421 let mut sync_config: SyncConfiguration = HashMap::new();
11422 sync_config.insert(
11423 "tm_test".to_string(),
11424 SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
11425 );
11426
11427 let mut engine = Engine::new(
11428 HashMap::new(),
11429 HashMap::new(),
11430 Box::new(MemoryStorage::new()),
11431 None,
11432 EngineConfig {
11433 sync_configuration: sync_config,
11434 ..Default::default()
11435 },
11436 );
11437
11438 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11439
11440 let result = engine.start_gasp_sync().await.unwrap();
11441 assert_eq!(result.topics_synced.len(), 1);
11442
11443 let topic_result = &result.topics_synced["tm_test"];
11444 assert_eq!(topic_result.peers.len(), 1);
11445 assert!(
11446 topic_result.errors.is_empty(),
11447 "Sync should succeed with mock remote"
11448 );
11449
11450 let last = engine
11452 .storage()
11453 .get_last_interaction("https://peer1.example.com", "tm_test")
11454 .await
11455 .unwrap();
11456 assert_eq!(
11459 last, 0,
11460 "cursor must remain at entry when no output completed"
11461 );
11462 }
11463
11464 #[tokio::test]
11465 async fn test_start_gasp_sync_with_factory_handles_error() {
11466 struct FailingFactory;
11467
11468 impl crate::gasp::GASPRemoteFactory for FailingFactory {
11469 fn create_remote(
11470 &self,
11471 _peer_url: &str,
11472 _topic: &str,
11473 ) -> Box<dyn crate::gasp::GASPRemote> {
11474 struct FailingRemote;
11475 #[async_trait(?Send)]
11476 impl crate::gasp::GASPRemote for FailingRemote {
11477 async fn get_initial_response(
11478 &self,
11479 _: &crate::types::GASPInitialRequest,
11480 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
11481 {
11482 Err(crate::gasp::GASPError::RemoteError(
11483 "connection refused".into(),
11484 ))
11485 }
11486 async fn get_initial_reply(
11487 &self,
11488 _: &crate::types::GASPInitialResponse,
11489 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
11490 {
11491 unreachable!()
11492 }
11493 async fn request_node(
11494 &self,
11495 _: &str,
11496 _: &str,
11497 _: u32,
11498 _: bool,
11499 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
11500 {
11501 unreachable!()
11502 }
11503 async fn submit_node(
11504 &self,
11505 _: &crate::types::GASPNode,
11506 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
11507 {
11508 unreachable!()
11509 }
11510 }
11511 Box::new(FailingRemote)
11512 }
11513 }
11514
11515 let mut sync_config: SyncConfiguration = HashMap::new();
11516 sync_config.insert(
11517 "tm_test".to_string(),
11518 SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
11519 );
11520
11521 let mut engine = Engine::new(
11522 HashMap::new(),
11523 HashMap::new(),
11524 Box::new(MemoryStorage::new()),
11525 None,
11526 EngineConfig {
11527 sync_configuration: sync_config,
11528 ..Default::default()
11529 },
11530 );
11531
11532 engine.set_gasp_remote_factory(Box::new(FailingFactory));
11533
11534 let result = engine.start_gasp_sync().await.unwrap();
11535 assert_eq!(result.topics_synced.len(), 1);
11536
11537 let topic_result = &result.topics_synced["tm_test"];
11538 assert_eq!(
11539 topic_result.errors.len(),
11540 1,
11541 "Should have one error for the failing peer"
11542 );
11543 assert!(topic_result.errors[0].contains("connection refused"));
11544 }
11545
11546 #[tokio::test]
11547 async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
11548 let mut sync_config: SyncConfiguration = HashMap::new();
11549 sync_config.insert(
11550 "tm_test".to_string(),
11551 SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
11552 );
11553
11554 let engine = Engine::new(
11556 HashMap::new(),
11557 HashMap::new(),
11558 Box::new(MemoryStorage::new()),
11559 None,
11560 EngineConfig {
11561 sync_configuration: sync_config,
11562 ..Default::default()
11563 },
11564 );
11565
11566 let result = engine.start_gasp_sync().await.unwrap();
11567 assert_eq!(result.topics_synced.len(), 1);
11568
11569 let topic_result = &result.topics_synced["tm_test"];
11570 assert_eq!(topic_result.peers.len(), 1);
11571 assert!(topic_result.errors.is_empty());
11572 }
11573
11574 #[tokio::test]
11575 async fn test_start_gasp_sync_with_factory_multiple_peers() {
11576 let mut sync_config: SyncConfiguration = HashMap::new();
11577 sync_config.insert(
11578 "tm_test".to_string(),
11579 SyncTarget::Peers(vec![
11580 "https://peer1.example.com".to_string(),
11581 "https://peer2.example.com".to_string(),
11582 ]),
11583 );
11584
11585 let mut engine = Engine::new(
11586 HashMap::new(),
11587 HashMap::new(),
11588 Box::new(MemoryStorage::new()),
11589 None,
11590 EngineConfig {
11591 sync_configuration: sync_config,
11592 ..Default::default()
11593 },
11594 );
11595
11596 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11597
11598 let result = engine.start_gasp_sync().await.unwrap();
11599 let topic_result = &result.topics_synced["tm_test"];
11600 assert_eq!(topic_result.peers.len(), 2);
11601 assert!(topic_result.errors.is_empty());
11602
11603 let last1 = engine
11605 .storage()
11606 .get_last_interaction("https://peer1.example.com", "tm_test")
11607 .await
11608 .unwrap();
11609 let last2 = engine
11610 .storage()
11611 .get_last_interaction("https://peer2.example.com", "tm_test")
11612 .await
11613 .unwrap();
11614 assert_eq!(last1, 0);
11615 assert_eq!(last2, 0);
11616 }
11617
11618 struct HangingRemote;
11624
11625 #[async_trait(?Send)]
11626 impl crate::gasp::GASPRemote for HangingRemote {
11627 async fn get_initial_response(
11628 &self,
11629 _: &crate::types::GASPInitialRequest,
11630 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
11631 std::future::pending::<()>().await;
11632 unreachable!()
11633 }
11634 async fn get_initial_reply(
11635 &self,
11636 _: &crate::types::GASPInitialResponse,
11637 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
11638 unreachable!()
11639 }
11640 async fn request_node(
11641 &self,
11642 _: &str,
11643 _: &str,
11644 _: u32,
11645 _: bool,
11646 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
11647 unreachable!()
11648 }
11649 async fn submit_node(
11650 &self,
11651 _: &crate::types::GASPNode,
11652 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
11653 unreachable!()
11654 }
11655 }
11656
11657 struct SelectiveHangFactory {
11661 created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
11662 }
11663
11664 impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
11665 fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11666 self.created.borrow_mut().push(peer_url.to_string());
11667 if peer_url.contains("hang.example.com") {
11668 Box::new(HangingRemote)
11669 } else {
11670 Box::new(MockSyncRemote {
11671 utxos: vec![crate::types::GASPOutput {
11672 txid: "remote_tx1".to_string(),
11673 output_index: 0,
11674 score: 100.0,
11675 }],
11676 })
11677 }
11678 }
11679 }
11680
11681 fn instant_deadline() -> crate::engine::SleepFactory {
11686 std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
11687 }
11688
11689 fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
11690 let mut sync_config: SyncConfiguration = HashMap::new();
11691 sync_config.insert(
11692 "tm_test".to_string(),
11693 SyncTarget::Peers(vec![
11694 "https://hang.example.com".to_string(),
11695 "https://good.example.com".to_string(),
11696 ]),
11697 );
11698 Engine::new(
11699 HashMap::new(),
11700 HashMap::new(),
11701 Box::new(store),
11702 None,
11703 EngineConfig {
11704 sync_configuration: sync_config,
11705 ..Default::default()
11706 },
11707 )
11708 }
11709
11710 #[tokio::test]
11711 async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
11712 let store = std::rc::Rc::new(MemoryStorage::new());
11713 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11714 let mut engine = two_peer_engine(store.clone());
11715 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11716 created: created.clone(),
11717 }));
11718 engine.set_peer_sync_budget(instant_deadline(), 1);
11719
11720 let result = engine.start_gasp_sync().await.unwrap();
11721 let topic_result = &result.topics_synced["tm_test"];
11722
11723 assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
11726 assert!(
11727 topic_result.errors[0].contains("budget"),
11728 "{:?}",
11729 topic_result.errors
11730 );
11731 assert_eq!(
11732 created.borrow().as_slice(),
11733 ["https://hang.example.com", "https://good.example.com"],
11734 "both peers attempted this tick"
11735 );
11736
11737 let hang_cursor = store
11740 .get_last_interaction("https://hang.example.com", "tm_test")
11741 .await
11742 .unwrap();
11743 assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
11744 let good_cursor = store
11745 .get_last_interaction("https://good.example.com", "tm_test")
11746 .await
11747 .unwrap();
11748 assert_eq!(good_cursor, 0, "no synthetic graph completed");
11749
11750 let hang_health = store
11752 .get_peer_sync_health("hang.example.com", "tm_test")
11753 .await
11754 .unwrap();
11755 assert_eq!(hang_health.consecutive_failures, 1);
11756 let good_health = store
11757 .get_peer_sync_health("good.example.com", "tm_test")
11758 .await
11759 .unwrap();
11760 assert_eq!(good_health.consecutive_failures, 0);
11761 }
11762
11763 #[tokio::test]
11764 async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
11765 let store = std::rc::Rc::new(MemoryStorage::new());
11766 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11767
11768 let mut sync_config: SyncConfiguration = HashMap::new();
11769 sync_config.insert(
11770 "tm_test".to_string(),
11771 SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
11772 );
11773 let mut engine = Engine::new(
11774 HashMap::new(),
11775 HashMap::new(),
11776 Box::new(store.clone()),
11777 None,
11778 EngineConfig {
11779 sync_configuration: sync_config,
11780 ..Default::default()
11781 },
11782 );
11783 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11784 created: created.clone(),
11785 }));
11786 engine.set_peer_sync_budget(instant_deadline(), 1);
11787
11788 for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
11792 engine.start_gasp_sync().await.unwrap();
11793 store.advance_clock(900);
11794 assert_eq!(
11795 created.borrow().len() as u64,
11796 i + 1,
11797 "peer attempted while below the threshold"
11798 );
11799 }
11800 let health = store
11801 .get_peer_sync_health("hang.example.com", "tm_test")
11802 .await
11803 .unwrap();
11804 assert_eq!(
11805 health.consecutive_failures,
11806 crate::gasp::PEER_QUARANTINE_THRESHOLD
11807 );
11808
11809 let result = engine.start_gasp_sync().await.unwrap();
11813 assert!(result.topics_synced["tm_test"].errors.is_empty());
11814 assert_eq!(
11815 created.borrow().len() as u64,
11816 crate::gasp::PEER_QUARANTINE_THRESHOLD,
11817 "quarantined peer must be skipped"
11818 );
11819
11820 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11822 engine.start_gasp_sync().await.unwrap();
11823 assert_eq!(
11824 created.borrow().len() as u64,
11825 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
11826 "re-probe attempted after the window"
11827 );
11828 let health = store
11830 .get_peer_sync_health("hang.example.com", "tm_test")
11831 .await
11832 .unwrap();
11833 assert_eq!(
11834 health.consecutive_failures,
11835 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
11836 );
11837 let result = engine.start_gasp_sync().await.unwrap();
11838 assert!(
11839 result.topics_synced["tm_test"].errors.is_empty(),
11840 "re-quarantined"
11841 );
11842
11843 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11847 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11849 let result = engine.start_gasp_sync().await.unwrap();
11850 assert!(result.topics_synced["tm_test"].errors.is_empty());
11851 let health = store
11852 .get_peer_sync_health("hang.example.com", "tm_test")
11853 .await
11854 .unwrap();
11855 assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
11856 }
11857
11858 use crate::broadcaster::ArcBroadcaster;
11861
11862 struct MockArcBroadcaster {
11863 calls: Arc<Mutex<Vec<String>>>,
11864 should_fail: bool,
11865 }
11866
11867 impl MockArcBroadcaster {
11868 fn new() -> Self {
11869 Self {
11870 calls: Arc::new(Mutex::new(Vec::new())),
11871 should_fail: false,
11872 }
11873 }
11874
11875 fn failing() -> Self {
11876 Self {
11877 calls: Arc::new(Mutex::new(Vec::new())),
11878 should_fail: true,
11879 }
11880 }
11881 }
11882
11883 #[async_trait(?Send)]
11884 impl ArcBroadcaster for MockArcBroadcaster {
11885 async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
11886 self.calls.lock().unwrap().push(raw_tx_hex.to_string());
11887 if self.should_fail {
11888 Err("mock ARC failure".to_string())
11889 } else {
11890 Ok("mock_txid_from_arc".to_string())
11891 }
11892 }
11893 }
11894
11895 fn make_engine_with_arc(
11897 admit_indices: Vec<u32>,
11898 arc: MockArcBroadcaster,
11899 ) -> (Engine, Arc<Mutex<Vec<String>>>) {
11900 let arc_calls = arc.calls.clone();
11901
11902 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11903 managers.insert(
11904 "tm_test".to_string(),
11905 Box::new(MockTopicManager::admitting(admit_indices)),
11906 );
11907
11908 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
11909 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
11910
11911 let storage = Box::new(MemoryStorage::new());
11912
11913 let engine = Engine::with_all(
11914 managers,
11915 lookup_services,
11916 storage,
11917 None,
11918 None,
11919 Some(Box::new(arc)),
11920 None,
11921 EngineConfig::default(),
11922 );
11923
11924 (engine, arc_calls)
11925 }
11926
11927 #[tokio::test]
11930 async fn test_arc_broadcaster_called_on_current_tx() {
11931 let arc = MockArcBroadcaster::new();
11932 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11933
11934 let beef = test_tagged_beef(vec!["tm_test"]);
11935 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11936
11937 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11938
11939 let calls = arc_calls.lock().unwrap();
11940 assert_eq!(
11941 calls.len(),
11942 1,
11943 "ARC broadcaster should be called once for CurrentTx"
11944 );
11945 assert!(
11946 !calls[0].is_empty(),
11947 "ARC should receive non-empty raw tx hex"
11948 );
11949 }
11950
11951 #[tokio::test]
11952 async fn test_arc_broadcaster_not_called_on_historical_tx() {
11953 let arc = MockArcBroadcaster::new();
11954 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11955
11956 let beef = test_tagged_beef(vec!["tm_test"]);
11957 engine
11958 .submit(&beef, SubmitMode::HistoricalTx)
11959 .await
11960 .unwrap();
11961
11962 assert_eq!(
11963 arc_calls.lock().unwrap().len(),
11964 0,
11965 "ARC broadcaster should NOT be called for HistoricalTx"
11966 );
11967 }
11968
11969 #[tokio::test]
11970 async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
11971 let arc = MockArcBroadcaster::new();
11972 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11973
11974 let beef = test_tagged_beef(vec!["tm_test"]);
11975 engine
11976 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
11977 .await
11978 .unwrap();
11979
11980 assert_eq!(
11981 arc_calls.lock().unwrap().len(),
11982 0,
11983 "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
11984 );
11985 }
11986
11987 #[tokio::test]
11988 async fn test_arc_broadcast_failure_does_not_fail_submit() {
11989 let arc = MockArcBroadcaster::failing();
11990 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11991
11992 let beef = test_tagged_beef(vec!["tm_test"]);
11993 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11995
11996 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11997
11998 assert_eq!(
12000 arc_calls.lock().unwrap().len(),
12001 1,
12002 "ARC broadcaster should be attempted even if it will fail"
12003 );
12004 }
12005
12006 #[tokio::test]
12007 async fn test_arc_broadcaster_not_present_still_works() {
12008 let engine = make_engine(vec![0]);
12010
12011 let beef = test_tagged_beef(vec!["tm_test"]);
12012 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
12013
12014 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
12015 }
12016
12017 struct TrackingAdvertiser {
12021 created: Arc<Mutex<Vec<AdvertisementData>>>,
12022 existing_ship: Vec<Advertisement>,
12023 existing_slap: Vec<Advertisement>,
12024 tag_topics: Vec<String>,
12028 create_fails: bool,
12030 find_fails: bool,
12032 revoke_empty: bool,
12034 }
12035
12036 impl TrackingAdvertiser {
12037 fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
12038 let created = Arc::new(Mutex::new(Vec::new()));
12039 (
12040 Self {
12041 created: created.clone(),
12042 existing_ship: vec![],
12043 existing_slap: vec![],
12044 tag_topics: vec!["tm_test".to_string()],
12045 create_fails: false,
12046 find_fails: false,
12047 revoke_empty: false,
12048 },
12049 created,
12050 )
12051 }
12052 }
12053
12054 use crate::advertiser::{Advertiser, AdvertiserError};
12055
12056 #[async_trait(?Send)]
12057 impl Advertiser for TrackingAdvertiser {
12058 async fn create_advertisements(
12059 &self,
12060 ads: &[AdvertisementData],
12061 ) -> Result<TaggedBEEF, AdvertiserError> {
12062 if self.create_fails {
12063 return Err(AdvertiserError::CreationFailed(
12064 "mock create refused".into(),
12065 ));
12066 }
12067 self.created.lock().unwrap().extend(ads.iter().cloned());
12068 Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
12069 }
12070
12071 async fn find_all_advertisements(
12072 &self,
12073 protocol: Protocol,
12074 ) -> Result<Vec<Advertisement>, AdvertiserError> {
12075 if self.find_fails {
12076 return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
12077 }
12078 match protocol {
12079 Protocol::Ship => Ok(self.existing_ship.clone()),
12080 Protocol::Slap => Ok(self.existing_slap.clone()),
12081 }
12082 }
12083
12084 async fn revoke_advertisements(
12085 &self,
12086 _ads: &[Advertisement],
12087 ) -> Result<TaggedBEEF, AdvertiserError> {
12088 if self.revoke_empty {
12089 return Ok(TaggedBEEF::new(vec![], vec![]));
12090 }
12091 Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
12092 }
12093
12094 fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
12095 None
12096 }
12097 }
12098
12099 #[tokio::test]
12103 async fn test_sync_advertisements_url_validation() {
12104 let (adv, created) = TrackingAdvertiser::new();
12106
12107 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12108 managers.insert(
12109 "tm_test".to_string(),
12110 Box::new(MockTopicManager::admitting(vec![0])),
12111 );
12112
12113 let engine = Engine::new(
12114 managers,
12115 HashMap::new(),
12116 Box::new(MemoryStorage::new()),
12117 Some(Box::new(adv)),
12118 EngineConfig {
12119 hosting_url: Some(String::new()), ..Default::default()
12121 },
12122 );
12123
12124 engine.sync_advertisements().await.unwrap();
12125 assert!(
12126 created.lock().unwrap().is_empty(),
12127 "Empty hosting URL should skip advertisement creation"
12128 );
12129
12130 let (adv2, created2) = TrackingAdvertiser::new();
12132
12133 let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12134 managers2.insert(
12135 "tm_test".to_string(),
12136 Box::new(MockTopicManager::admitting(vec![0])),
12137 );
12138 managers2.insert(
12140 "tm_ship".to_string(),
12141 Box::new(MockTopicManager::admitting(vec![])),
12142 );
12143
12144 let engine2 = Engine::new(
12145 managers2,
12146 HashMap::new(),
12147 Box::new(MemoryStorage::new()),
12148 Some(Box::new(adv2)),
12149 EngineConfig {
12150 hosting_url: Some("https://valid.example.com".to_string()),
12151 suppress_default_sync_advertisements: true,
12152 ..Default::default()
12153 },
12154 );
12155
12156 engine2.sync_advertisements().await.unwrap();
12157 let ads = created2.lock().unwrap();
12158 assert!(
12160 ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
12161 "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
12162 );
12163 assert!(
12164 ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
12165 "tm_test should get a SHIP advertisement"
12166 );
12167 }
12168
12169 fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
12178 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12179 managers.insert(
12180 "tm_test".to_string(),
12181 Box::new(MockTopicManager::admitting(vec![0])),
12182 );
12183 Engine::new(
12184 managers,
12185 HashMap::new(),
12186 Box::new(MemoryStorage::new()),
12187 Some(Box::new(adv)),
12188 EngineConfig {
12189 hosting_url: Some("https://valid.example.com".to_string()),
12190 suppress_default_sync_advertisements: true,
12191 ..Default::default()
12192 },
12193 )
12194 }
12195
12196 #[tokio::test]
12197 async fn sync_report_counts_admitted_outputs_on_success() {
12198 let (adv, _) = TrackingAdvertiser::new();
12199 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12200 assert!(report.ok(), "clean run must report ok: {report:?}");
12201 assert_eq!(report.to_create, 1, "tm_test was missing");
12202 assert_eq!(
12203 report.admitted.get("tm_test"),
12204 Some(&1),
12205 "the STEAK's admitted count must be surfaced: {report:?}"
12206 );
12207 }
12208
12209 #[tokio::test]
12210 async fn sync_report_surfaces_local_submit_failure() {
12211 let (mut adv, _) = TrackingAdvertiser::new();
12215 adv.tag_topics = vec!["tm_unregistered".to_string()];
12216 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12217 assert!(!report.ok(), "a failed local submit must not report ok");
12218 assert_eq!(report.to_create, 1);
12219 let submit_error = report.submit_error.as_deref().unwrap_or_default();
12220 assert!(
12221 submit_error.contains("tm_unregistered"),
12222 "verbatim engine error expected, got: {submit_error}"
12223 );
12224 assert!(report.admitted.is_empty());
12225 }
12226
12227 #[tokio::test]
12228 async fn sync_report_surfaces_create_failure() {
12229 let (mut adv, _) = TrackingAdvertiser::new();
12230 adv.create_fails = true;
12231 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12232 assert!(!report.ok());
12233 assert!(
12234 report
12235 .create_error
12236 .as_deref()
12237 .unwrap_or_default()
12238 .contains("mock create refused"),
12239 "{report:?}"
12240 );
12241 assert!(report.submit_error.is_none(), "create failed before submit");
12242 }
12243
12244 #[tokio::test]
12245 async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
12246 let (mut adv, created) = TrackingAdvertiser::new();
12250 adv.existing_ship = vec![Advertisement {
12251 protocol: Protocol::Ship,
12252 identity_key: "02aa".to_string(),
12253 domain: "https://valid.example.com".to_string(),
12254 topic_or_service: "tm_test".to_string(),
12255 beef: None,
12256 output_index: None,
12257 }];
12258 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12259 assert!(report.ok(), "{report:?}");
12260 assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
12261 assert!(created.lock().unwrap().is_empty());
12262 assert!(
12263 report.effective(),
12264 "converged-noop is the one zero-admit shape that IS success"
12265 );
12266 }
12267
12268 #[tokio::test]
12269 async fn sync_report_zero_admit_is_ok_but_not_effective() {
12270 let (adv, _) = TrackingAdvertiser::new();
12276 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12277 managers.insert(
12278 "tm_test".to_string(),
12279 Box::new(MockTopicManager::admitting(vec![])), );
12281 let engine = Engine::new(
12282 managers,
12283 HashMap::new(),
12284 Box::new(MemoryStorage::new()),
12285 Some(Box::new(adv)),
12286 EngineConfig {
12287 hosting_url: Some("https://valid.example.com".to_string()),
12288 suppress_default_sync_advertisements: true,
12289 ..Default::default()
12290 },
12291 );
12292 let report = engine.sync_advertisements().await.unwrap();
12293 assert!(report.ok(), "no ERROR occurred: {report:?}");
12294 assert_eq!(report.to_create, 1);
12295 assert_eq!(report.admitted_total(), 0, "{report:?}");
12296 assert!(
12297 !report.effective(),
12298 "zero admitted on a non-empty create must not read as success: {report:?}"
12299 );
12300 }
12301
12302 #[tokio::test]
12303 async fn sync_report_partial_admit_is_not_effective() {
12304 let (mut adv, _) = TrackingAdvertiser::new();
12310 adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
12311 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12312 managers.insert(
12313 "tm_test".to_string(),
12314 Box::new(MockTopicManager::admitting(vec![0])),
12315 );
12316 managers.insert(
12317 "tm_refuse".to_string(),
12318 Box::new(MockTopicManager::admitting(vec![])), );
12320 let engine = Engine::new(
12321 managers,
12322 HashMap::new(),
12323 Box::new(MemoryStorage::new()),
12324 Some(Box::new(adv)),
12325 EngineConfig {
12326 hosting_url: Some("https://valid.example.com".to_string()),
12327 suppress_default_sync_advertisements: true,
12328 ..Default::default()
12329 },
12330 );
12331 let report = engine.sync_advertisements().await.unwrap();
12332 assert!(report.ok(), "no ERROR occurred: {report:?}");
12333 assert_eq!(report.to_create, 2, "{report:?}");
12334 assert_eq!(report.admitted_total(), 1, "{report:?}");
12335 assert!(
12336 !report.effective(),
12337 "a partial admit must not read as success: {report:?}"
12338 );
12339 }
12340
12341 #[tokio::test]
12342 async fn sync_report_lookup_failure_refuses_creation() {
12343 let (mut adv, created) = TrackingAdvertiser::new();
12347 adv.find_fails = true;
12348 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12349 assert!(!report.ok(), "{report:?}");
12350 assert!(!report.effective());
12351 assert!(
12352 report
12353 .lookup_error
12354 .as_deref()
12355 .unwrap_or_default()
12356 .contains("mock lookup refused"),
12357 "{report:?}"
12358 );
12359 assert_eq!(report.to_create, 0, "creation refused, not attempted");
12360 assert!(
12361 created.lock().unwrap().is_empty(),
12362 "no create call on a blind read"
12363 );
12364 assert!(report.submit_error.is_none());
12365 }
12366
12367 #[tokio::test]
12368 async fn sync_report_counts_skipped_revocations() {
12369 let (mut adv, _) = TrackingAdvertiser::new();
12372 adv.revoke_empty = true;
12373 adv.existing_ship = vec![
12374 Advertisement {
12375 protocol: Protocol::Ship,
12376 identity_key: "02aa".to_string(),
12377 domain: "https://valid.example.com".to_string(),
12378 topic_or_service: "tm_test".to_string(),
12379 beef: None,
12380 output_index: None,
12381 },
12382 Advertisement {
12383 protocol: Protocol::Ship,
12384 identity_key: "02aa".to_string(),
12385 domain: "https://valid.example.com".to_string(),
12386 topic_or_service: "tm_stale".to_string(), beef: None,
12388 output_index: None,
12389 },
12390 ];
12391 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12392 assert!(report.ok(), "{report:?}");
12393 assert_eq!(report.to_revoke, 1, "tm_stale is stale");
12394 assert_eq!(
12395 report.revoke_skipped, 1,
12396 "the v1 no-op must be visible, not silent: {report:?}"
12397 );
12398 assert!(report.revoke_error.is_none());
12399 assert!(report.revoke_submit_error.is_none());
12400 }
12401
12402 use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
12405 use std::cell::Cell;
12406
12407 const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12409
12410 fn proofless_beef() -> (Vec<u8>, String) {
12412 use bsv_rs::transaction::{Beef, Transaction};
12413 let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
12414 let txid = tx.id();
12415 let mut beef = Beef::new();
12416 beef.merge_transaction(tx);
12417 (beef.to_binary(), txid)
12418 }
12419
12420 fn subject_not_last_beef() -> (Vec<u8>, String, String) {
12430 use bsv_rs::transaction::Beef;
12431 let parsed = Beef::from_binary(&test_beef()).unwrap();
12432 let parent = parsed.txs[0].tx().unwrap().clone();
12433 let child = parsed.txs[1].tx().unwrap().clone();
12434 let (parent_id, child_id) = (parent.id(), child.id());
12435 assert_ne!(parent_id, child_id);
12436
12437 let mut beef = Beef::new();
12443 beef.merge_transaction(child); beef.merge_transaction(parent);
12445 let mut w = bsv_rs::primitives::encoding::Writer::new();
12446 beef.to_writer(&mut w);
12447 let bytes = w.into_bytes();
12448
12449 let reread = Beef::from_binary(&bytes).unwrap();
12452 assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
12453 (bytes, child_id, parent_id)
12454 }
12455
12456 #[test]
12457 fn stitch_preserves_the_subject_when_it_is_not_last() {
12458 use bsv_rs::transaction::{Beef, MerklePath};
12459 let (stored, child_id, parent_id) = subject_not_last_beef();
12460 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
12461
12462 let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
12463 .expect("stitch must succeed on a beef that contains the txid");
12464
12465 let after = Beef::from_binary(&out).unwrap();
12466 assert!(
12467 after.find_txid(&child_id).is_some(),
12468 "the SUBJECT must survive its own proof-completion"
12469 );
12470 assert!(
12471 after.find_txid(&parent_id).is_some(),
12472 "the ancestry must survive too"
12473 );
12474 assert!(
12475 after.find_bump(&child_id).is_some(),
12476 "the stitched bump must prove the subject"
12477 );
12478 assert!(
12479 after.find_bump(&parent_id).is_none(),
12480 "the parent must NOT be claimed by the child's bump"
12481 );
12482 }
12483
12484 #[test]
12489 fn the_old_round_trip_demonstrably_dropped_the_subject() {
12490 use bsv_rs::transaction::{Beef, Transaction};
12491 let (stored, child_id, _parent_id) = subject_not_last_beef();
12492 let tx = Transaction::from_beef(&stored, None).unwrap();
12493 assert_ne!(
12494 tx.id(),
12495 child_id,
12496 "from_beef(None) picks txs.last(), not the subject — the trigger"
12497 );
12498 let rebuilt = tx.to_beef(true).unwrap();
12499 let reparsed = Beef::from_binary(&rebuilt).unwrap();
12500 assert!(
12501 reparsed.find_txid(&child_id).is_none(),
12502 "the rebuild loses the subject — exactly the #284 artifact"
12503 );
12504 }
12505
12506 #[test]
12512 fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
12513 use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
12514 let parent = Transaction::from_hex(RAW_PARENT).unwrap();
12516 let parent_id = parent.id();
12517 let subject = child_of(&parent_id, 0);
12518 let subject_id = subject.id();
12519 let orphan = MerklePath::new_unchecked(
12522 965_771,
12523 vec![vec![
12524 MerklePathLeaf::new_txid(0, subject_id.clone()),
12525 MerklePathLeaf::new(1, "aa".repeat(32)),
12526 ]],
12527 )
12528 .unwrap();
12529 let mut beef = Beef::new();
12530 let bi = beef.merge_bump(orphan.clone());
12531 beef.merge_raw_tx(parent.to_binary(), None);
12532 beef.merge_raw_tx(subject.to_binary(), Some(bi));
12533 let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
12534 let stored = beef.to_binary();
12535 assert_eq!(
12536 Beef::from_binary(&stored)
12537 .unwrap()
12538 .find_bump(&subject_id)
12539 .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
12540 Some(orphan_root.clone())
12541 );
12542 let canonical = MerklePath::new_unchecked(
12544 965_771,
12545 vec![vec![
12546 MerklePathLeaf::new_txid(0, subject_id.clone()),
12547 MerklePathLeaf::new(1, "bb".repeat(32)),
12548 ]],
12549 )
12550 .unwrap();
12551 let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
12552 assert_ne!(
12553 canonical_root, orphan_root,
12554 "the reorg shape: same height, new root"
12555 );
12556 let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
12557 let after = Beef::from_binary(&out).unwrap();
12558 let own = after
12560 .find_txid(&subject_id)
12561 .and_then(BeefTx::bump_index)
12562 .and_then(|bi| after.bumps.get(bi))
12563 .unwrap();
12564 assert_eq!(
12565 own.compute_root(Some(&subject_id)).unwrap(),
12566 canonical_root,
12567 "the subject re-anchored to the new bump"
12568 );
12569 assert_eq!(
12571 after.bumps.len(),
12572 1,
12573 "the unreferenced orphan bump was dropped"
12574 );
12575 assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
12577 }
12578
12579 #[test]
12580 fn stitch_refuses_a_beef_that_lacks_the_txid() {
12581 use bsv_rs::transaction::MerklePath;
12582 let (stored, _c, _p) = subject_not_last_beef();
12583 let foreign = "aa".repeat(32);
12584 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
12585 assert!(
12586 Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
12587 "no txid in the beef -> no write, never garbage"
12588 );
12589 }
12590
12591 const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12593
12594 fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
12596 let mut sx = String::from("0100000001");
12597 let mut prev = hex::decode(source_txid).unwrap();
12598 prev.reverse();
12599 sx.push_str(&hex::encode(prev));
12600 sx.push_str(&hex::encode(vout.to_le_bytes()));
12601 sx.push_str("00ffffffff01");
12602 sx.push_str(&hex::encode(1000u64.to_le_bytes()));
12603 sx.push_str("015100000000");
12604 bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
12605 }
12606
12607 fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
12609 use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
12610 let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
12611 MerklePath::new_unchecked(height, vec![vec![leaf]])
12612 .unwrap()
12613 .to_hex()
12614 }
12615
12616 struct StubFetcher {
12619 proof: Option<String>,
12620 calls: Cell<u32>,
12621 }
12622
12623 #[async_trait(?Send)]
12624 impl AncestorFetcher for StubFetcher {
12625 async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
12626 self.calls.set(self.calls.get() + 1);
12627 Ok(FetchedAncestor {
12628 raw_tx: PROOFLESS_RAW_TX.to_string(),
12629 proof: self.proof.clone(),
12630 })
12631 }
12632
12633 async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
12639 true
12640 }
12641 }
12642
12643 async fn engine_with_proofless_output(
12646 fetcher: Option<std::rc::Rc<StubFetcher>>,
12647 ) -> (Engine, String) {
12648 let (beef, txid) = proofless_beef();
12649 let storage = MemoryStorage::new();
12650 storage
12651 .insert_output(&Output {
12652 txid: txid.clone(),
12653 output_index: 0,
12654 output_script: vec![0x76],
12655 satoshis: 1_000_000_000,
12656 topic: "Hello".to_string(),
12657 spent: false,
12658 outputs_consumed: vec![],
12659 consumed_by: vec![],
12660 beef: Some(beef),
12661 block_height: None,
12662 score: Some(1.0),
12663 })
12664 .await
12665 .unwrap();
12666
12667 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12668 managers.insert(
12669 "Hello".into(),
12670 Box::new(MockTopicManager::admitting(vec![0])),
12671 );
12672 let mut engine = Engine::new(
12673 managers,
12674 HashMap::new(),
12675 Box::new(storage),
12676 None,
12677 EngineConfig::default(),
12678 );
12679 if let Some(f) = fetcher {
12680 engine.set_ancestor_fetcher(f);
12681 }
12682 (engine, txid)
12683 }
12684
12685 #[tokio::test]
12686 async fn proofs_completion_uses_dedicated_courier() {
12687 let (_, txid) = proofless_beef();
12688 let shared = std::rc::Rc::new(StubFetcher {
12689 proof: None,
12690 calls: Cell::new(0),
12691 });
12692 let dedicated = StubFetcher {
12693 proof: Some(single_leaf_bump_hex(&txid, 850_000)),
12694 calls: Cell::new(0),
12695 };
12696 let (engine, _) = engine_with_proofless_output(Some(shared.clone())).await;
12697 assert_eq!(
12698 engine
12699 .complete_missing_proofs(1, 0)
12700 .await
12701 .unwrap()
12702 .completed,
12703 0
12704 );
12705 assert_eq!(
12706 engine
12707 .complete_missing_proofs_with(&dedicated, 1, 0)
12708 .await
12709 .unwrap()
12710 .completed,
12711 1
12712 );
12713 assert_eq!(shared.calls.get(), 1);
12714 assert_eq!(dedicated.calls.get(), 1);
12715 let output = engine
12716 .storage()
12717 .find_output(&txid, 0, Some("Hello"), None, true)
12718 .await
12719 .unwrap()
12720 .unwrap();
12721 assert!(
12722 crate::beef_limits::own_proof(output.beef.as_ref().unwrap(), &txid)
12723 .unwrap()
12724 .is_some()
12725 );
12726 }
12727
12728 #[tokio::test]
12729 async fn complete_missing_proofs_noop_without_fetcher() {
12730 let (engine, _txid) = engine_with_proofless_output(None).await;
12732 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12733 assert_eq!(summary, ProofCompletionSummary::default());
12734 }
12735
12736 #[tokio::test]
12737 async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
12738 let (_beef, txid) = proofless_beef();
12739 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12740 let fetcher = std::rc::Rc::new(StubFetcher {
12741 proof: Some(proof_hex),
12742 calls: Cell::new(0),
12743 });
12744 let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12745
12746 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12747 assert_eq!(summary.scanned, 1);
12748 assert_eq!(summary.proofless, 1);
12749 assert_eq!(
12750 summary.completed, 1,
12751 "the proofless BEEF should be completed"
12752 );
12753 assert_eq!(fetcher.calls.get(), 1);
12754
12755 let out = engine
12758 .storage()
12759 .find_output(&txid, 0, Some("Hello"), None, true)
12760 .await
12761 .unwrap()
12762 .unwrap();
12763 assert_eq!(out.block_height, Some(850_000));
12764 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
12765 assert!(
12766 beef.find_txid(&txid)
12767 .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
12768 "completed BEEF must prove the target tx"
12769 );
12770
12771 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
12774 assert_eq!(summary2.proofless, 0);
12775 assert_eq!(summary2.completed, 0);
12776 assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
12777 }
12778
12779 #[tokio::test]
12780 async fn complete_missing_proofs_skips_unconfirmed() {
12781 let fetcher = std::rc::Rc::new(StubFetcher {
12784 proof: None,
12785 calls: Cell::new(0),
12786 });
12787 let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12788 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12789 assert_eq!(summary.proofless, 1);
12790 assert_eq!(summary.completed, 0);
12791 assert_eq!(summary.still_unconfirmed, 1);
12792 assert_eq!(fetcher.calls.get(), 1);
12793 }
12794
12795 async fn engine_with_proofless_output_shared(
12800 fetcher: Option<std::rc::Rc<StubFetcher>>,
12801 ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
12802 let (beef, txid) = proofless_beef();
12803 let storage = std::rc::Rc::new(MemoryStorage::new());
12804 storage
12805 .insert_output(&Output {
12806 txid: txid.clone(),
12807 output_index: 0,
12808 output_script: vec![0x76],
12809 satoshis: 1_000_000_000,
12810 topic: "Hello".to_string(),
12811 spent: false,
12812 outputs_consumed: vec![],
12813 consumed_by: vec![],
12814 beef: Some(beef),
12815 block_height: None,
12816 score: Some(1.0),
12817 })
12818 .await
12819 .unwrap();
12820
12821 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12822 managers.insert(
12823 "Hello".into(),
12824 Box::new(MockTopicManager::admitting(vec![0])),
12825 );
12826 let mut engine = Engine::new(
12827 managers,
12828 HashMap::new(),
12829 Box::new(storage.clone()),
12830 None,
12831 EngineConfig::default(),
12832 );
12833 if let Some(f) = fetcher {
12834 engine.set_ancestor_fetcher(f);
12835 }
12836 (engine, txid, storage)
12837 }
12838
12839 #[tokio::test]
12840 async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
12841 let proof_hex = {
12845 let (_beef, txid) = proofless_beef();
12846 single_leaf_bump_hex(&txid, 850_000)
12847 };
12848 let fetcher = std::rc::Rc::new(StubFetcher {
12849 proof: Some(proof_hex),
12850 calls: Cell::new(0),
12851 });
12852 let (engine, _txid, storage) =
12853 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12854
12855 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12856 assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
12857 assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
12858
12859 storage.advance_clock(1800);
12864 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12865 assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
12866 assert_eq!(summary.completed, 1, "the backstop completes the proof");
12867 assert_eq!(fetcher.calls.get(), 1);
12868 }
12869
12870 #[tokio::test]
12871 async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
12872 let fetcher = std::rc::Rc::new(StubFetcher {
12878 proof: None,
12879 calls: Cell::new(0),
12880 });
12881 let (engine, txid, storage) =
12882 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12883
12884 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12887 engine
12888 .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
12889 .await
12890 .unwrap();
12891
12892 storage.advance_clock(1_000_000);
12895 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12896 assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
12897 assert_eq!(summary.completed, 0);
12898 assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
12899 }
12900
12901 #[tokio::test]
12902 async fn complete_missing_proofs_respects_limit() {
12903 let (beef1, txid1) = proofless_beef();
12905 let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
12907 let (beef2, txid2) = {
12908 use bsv_rs::transaction::{Beef, Transaction};
12909 let tx = Transaction::from_hex(raw2).unwrap();
12910 let id = tx.id();
12911 let mut b = Beef::new();
12912 b.merge_transaction(tx);
12913 (b.to_binary(), id)
12914 };
12915
12916 let storage = MemoryStorage::new();
12917 for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
12918 storage
12919 .insert_output(&Output {
12920 txid: txid.clone(),
12921 output_index: 0,
12922 output_script: vec![0x76],
12923 satoshis: 1000,
12924 topic: "Hello".to_string(),
12925 spent: false,
12926 outputs_consumed: vec![],
12927 consumed_by: vec![],
12928 beef: Some(beef),
12929 block_height: None,
12930 score: Some(1.0),
12931 })
12932 .await
12933 .unwrap();
12934 }
12935
12936 let fetcher = std::rc::Rc::new(StubFetcher {
12937 proof: None,
12938 calls: Cell::new(0),
12939 });
12940 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12941 managers.insert(
12942 "Hello".into(),
12943 Box::new(MockTopicManager::admitting(vec![0])),
12944 );
12945 let mut engine = Engine::new(
12946 managers,
12947 HashMap::new(),
12948 Box::new(storage),
12949 None,
12950 EngineConfig::default(),
12951 );
12952 engine.set_ancestor_fetcher(fetcher.clone());
12953
12954 let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
12955 assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
12956 }
12957
12958 #[tokio::test]
12959 async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
12960 use bsv_rs::transaction::{Beef, MerklePath, Transaction};
12969
12970 let storage = MemoryStorage::new();
12971
12972 let proven_raws = [
12977 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
12978 "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
12979 ];
12980 let mut proven_ids = Vec::new();
12981 for raw in proven_raws {
12982 let mut tx = Transaction::from_hex(raw).unwrap();
12983 let id = tx.id();
12984 tx.merkle_path =
12985 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
12986 let proven_beef = tx.to_beef(true).unwrap();
12989 assert_eq!(
12990 Beef::from_binary(&proven_beef)
12991 .unwrap()
12992 .find_txid(&id)
12993 .map(bsv_rs::transaction::BeefTx::has_proof),
12994 Some(true),
12995 "proven fixture row must carry its proof"
12996 );
12997 proven_ids.push(id.clone());
12998 storage
12999 .insert_output(&Output {
13000 txid: id,
13001 output_index: 0,
13002 output_script: vec![0x76],
13003 satoshis: 1000,
13004 topic: "Hello".to_string(),
13005 spent: false,
13006 outputs_consumed: vec![],
13007 consumed_by: vec![],
13008 beef: Some(proven_beef),
13009 block_height: Some(800_000),
13010 score: Some(1.0),
13011 })
13012 .await
13013 .unwrap();
13014 }
13015
13016 let fetcher = std::rc::Rc::new(StubFetcher {
13017 proof: None,
13018 calls: Cell::new(0),
13019 });
13020 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
13021 managers.insert(
13022 "Hello".into(),
13023 Box::new(MockTopicManager::admitting(vec![0])),
13024 );
13025 let mut engine = Engine::new(
13026 managers,
13027 HashMap::new(),
13028 Box::new(storage),
13029 None,
13030 EngineConfig::default(),
13031 );
13032 engine.set_ancestor_fetcher(fetcher.clone());
13033
13034 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
13037 assert_eq!(summary.scanned, proven_ids.len());
13038 assert_eq!(summary.proofless, 0);
13039 assert_eq!(summary.completed, 0);
13040 assert_eq!(
13041 summary.already_proven,
13042 proven_ids.len(),
13043 "every already-proven row is marked this pass"
13044 );
13045 assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
13046
13047 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
13050 assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
13051 assert_eq!(summary2.already_proven, 0);
13052 }
13053
13054 #[tokio::test]
13055 async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
13056 use bsv_rs::transaction::{MerklePath, Transaction};
13062
13063 let storage = MemoryStorage::new();
13064
13065 let proven_raws = [
13066 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
13067 ];
13068 for raw in proven_raws {
13069 let mut tx = Transaction::from_hex(raw).unwrap();
13070 let id = tx.id();
13071 tx.merkle_path =
13072 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
13073 let proven_beef = tx.to_beef(true).unwrap();
13074 storage
13075 .insert_output(&Output {
13076 txid: id,
13077 output_index: 0,
13078 output_script: vec![0x76],
13079 satoshis: 1000,
13080 topic: "Hello".to_string(),
13081 spent: false,
13082 outputs_consumed: vec![],
13083 consumed_by: vec![],
13084 beef: Some(proven_beef),
13085 block_height: Some(800_000),
13086 score: Some(1.0),
13087 })
13088 .await
13089 .unwrap();
13090 }
13091
13092 let (proofless, proofless_txid) = proofless_beef();
13094 storage
13095 .insert_output(&Output {
13096 txid: proofless_txid.clone(),
13097 output_index: 0,
13098 output_script: vec![0x76],
13099 satoshis: 1000,
13100 topic: "Hello".to_string(),
13101 spent: false,
13102 outputs_consumed: vec![],
13103 consumed_by: vec![],
13104 beef: Some(proofless),
13105 block_height: None,
13106 score: Some(1.0),
13107 })
13108 .await
13109 .unwrap();
13110
13111 let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
13112 let fetcher = std::rc::Rc::new(StubFetcher {
13113 proof: Some(proof_hex),
13114 calls: Cell::new(0),
13115 });
13116 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
13117 managers.insert(
13118 "Hello".into(),
13119 Box::new(MockTopicManager::admitting(vec![0])),
13120 );
13121 let mut engine = Engine::new(
13122 managers,
13123 HashMap::new(),
13124 Box::new(storage),
13125 None,
13126 EngineConfig::default(),
13127 );
13128 engine.set_ancestor_fetcher(fetcher.clone());
13129
13130 let mut completed_total = 0;
13133 for _ in 0..5 {
13134 let s = engine.complete_missing_proofs(1, 0).await.unwrap();
13135 completed_total += s.completed;
13136 if completed_total > 0 {
13137 break;
13138 }
13139 }
13140 assert_eq!(
13141 completed_total, 1,
13142 "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
13143 );
13144
13145 let out = engine
13146 .storage()
13147 .find_output(&proofless_txid, 0, Some("Hello"), None, true)
13148 .await
13149 .unwrap()
13150 .unwrap();
13151 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
13152 assert_eq!(
13153 beef.find_txid(&proofless_txid)
13154 .map(bsv_rs::transaction::BeefTx::has_proof),
13155 Some(true),
13156 "the old proofless tx now carries its proof"
13157 );
13158 }
13159
13160 use crate::storage::StorageError;
13168 use std::rc::Rc;
13169
13170 struct FaultingStorage {
13175 inner: MemoryStorage,
13176 fail_insert_output: Cell<bool>,
13177 fail_find_output: Cell<bool>,
13178 }
13179
13180 impl FaultingStorage {
13181 fn new() -> Rc<Self> {
13182 Rc::new(Self {
13183 inner: MemoryStorage::new(),
13184 fail_insert_output: Cell::new(false),
13185 fail_find_output: Cell::new(false),
13186 })
13187 }
13188 }
13189
13190 #[async_trait(?Send)]
13191 impl Storage for FaultingStorage {
13192 async fn insert_output_with_origin(
13193 &self,
13194 output: &Output,
13195 gasp: bool,
13196 ) -> Result<(), StorageError> {
13197 if self.fail_insert_output.get() {
13198 return Err(StorageError::Database(
13199 "D1_ERROR: storage overloaded".into(),
13200 ));
13201 }
13202 self.inner.insert_output_with_origin(output, gasp).await
13203 }
13204 async fn read_fault_memos(
13205 &self,
13206 host: &str,
13207 topic: &str,
13208 outpoints: Option<&[String]>,
13209 ) -> Result<crate::fault_memo::FaultMemoBatch, StorageError> {
13210 self.inner.read_fault_memos(host, topic, outpoints).await
13211 }
13212
13213 async fn put_deferred_graph(
13214 &self,
13215 record: &crate::gasp::DeferredGraph,
13216 ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
13217 self.inner.put_deferred_graph(record).await
13218 }
13219 async fn reserve_unanchorable_retry(
13220 &self,
13221 record: &crate::gasp::DeferredGraph,
13222 ) -> Result<bool, StorageError> {
13223 self.inner.reserve_unanchorable_retry(record).await
13224 }
13225 async fn find_deferred_graphs(
13226 &self,
13227 host: &str,
13228 topic: &str,
13229 ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
13230 self.inner.find_deferred_graphs(host, topic).await
13231 }
13232 async fn get_deferred_graph(
13233 &self,
13234 host: &str,
13235 topic: &str,
13236 outpoint: &str,
13237 ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
13238 self.inner.get_deferred_graph(host, topic, outpoint).await
13239 }
13240 async fn delete_deferred_graph(
13241 &self,
13242 host: &str,
13243 topic: &str,
13244 outpoint: &str,
13245 ) -> Result<(), StorageError> {
13246 self.inner
13247 .delete_deferred_graph(host, topic, outpoint)
13248 .await
13249 }
13250 async fn record_peer_sync_outcome(
13251 &self,
13252 host: &str,
13253 topic: &str,
13254 success: bool,
13255 ) -> Result<(), StorageError> {
13256 self.inner
13257 .record_peer_sync_outcome(host, topic, success)
13258 .await
13259 }
13260 async fn get_peer_sync_health(
13261 &self,
13262 host: &str,
13263 topic: &str,
13264 ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
13265 self.inner.get_peer_sync_health(host, topic).await
13266 }
13267 async fn record_peer_sync_yield(
13268 &self,
13269 host: &str,
13270 topic: &str,
13271 yielded: bool,
13272 ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
13273 self.inner
13274 .record_peer_sync_yield(host, topic, yielded)
13275 .await
13276 }
13277 async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
13278 self.insert_output_with_origin(output, false).await
13279 }
13280 async fn delete_output(
13281 &self,
13282 txid: &str,
13283 output_index: u32,
13284 topic: &str,
13285 ) -> Result<(), StorageError> {
13286 self.inner.delete_output(txid, output_index, topic).await
13287 }
13288 async fn mark_utxo_as_spent(
13289 &self,
13290 txid: &str,
13291 output_index: u32,
13292 topic: &str,
13293 ) -> Result<(), StorageError> {
13294 self.inner
13295 .mark_utxo_as_spent(txid, output_index, topic)
13296 .await
13297 }
13298 async fn update_consumed_by(
13299 &self,
13300 txid: &str,
13301 output_index: u32,
13302 topic: &str,
13303 consumed_by: &[Outpoint],
13304 ) -> Result<(), StorageError> {
13305 self.inner
13306 .update_consumed_by(txid, output_index, topic, consumed_by)
13307 .await
13308 }
13309 async fn update_transaction_beef(
13310 &self,
13311 txid: &str,
13312 beef: &[u8],
13313 ) -> Result<(), StorageError> {
13314 self.inner.update_transaction_beef(txid, beef).await
13315 }
13316 async fn insert_applied_transaction(
13317 &self,
13318 tx: &AppliedTransaction,
13319 ) -> Result<(), StorageError> {
13320 self.inner.insert_applied_transaction(tx).await
13321 }
13322 async fn does_applied_transaction_exist(
13323 &self,
13324 tx: &AppliedTransaction,
13325 ) -> Result<bool, StorageError> {
13326 self.inner.does_applied_transaction_exist(tx).await
13327 }
13328 async fn delete_applied_transaction(
13329 &self,
13330 tx: &AppliedTransaction,
13331 ) -> Result<(), StorageError> {
13332 self.inner.delete_applied_transaction(tx).await
13333 }
13334 async fn find_output(
13335 &self,
13336 txid: &str,
13337 output_index: u32,
13338 topic: Option<&str>,
13339 spent: Option<bool>,
13340 include_beef: bool,
13341 ) -> Result<Option<Output>, StorageError> {
13342 if self.fail_find_output.get() {
13343 return Err(StorageError::Database("D1_ERROR: read timed out".into()));
13344 }
13345 self.inner
13346 .find_output(txid, output_index, topic, spent, include_beef)
13347 .await
13348 }
13349 async fn find_outputs_for_transaction(
13350 &self,
13351 txid: &str,
13352 include_beef: bool,
13353 ) -> Result<Vec<Output>, StorageError> {
13354 self.inner
13355 .find_outputs_for_transaction(txid, include_beef)
13356 .await
13357 }
13358 async fn find_utxos_for_topic(
13359 &self,
13360 topic: &str,
13361 since: Option<f64>,
13362 limit: Option<u64>,
13363 include_beef: bool,
13364 ) -> Result<Vec<Output>, StorageError> {
13365 self.inner
13366 .find_utxos_for_topic(topic, since, limit, include_beef)
13367 .await
13368 }
13369 async fn update_last_interaction(
13370 &self,
13371 host: &str,
13372 topic: &str,
13373 since: u64,
13374 ) -> Result<(), StorageError> {
13375 self.inner.update_last_interaction(host, topic, since).await
13376 }
13377 async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
13378 self.inner.get_last_interaction(host, topic).await
13379 }
13380 }
13381
13382 fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
13383 let storage = FaultingStorage::new();
13384 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13385 managers.insert(
13386 "tm_test".to_string(),
13387 Box::new(MockTopicManager::admitting(admit_indices)),
13388 );
13389 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13390 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
13391 let engine = Engine::new(
13392 managers,
13393 lookup_services,
13394 Box::new(Rc::clone(&storage)),
13395 None,
13396 EngineConfig::default(),
13397 );
13398 (engine, storage)
13399 }
13400
13401 async fn applied(storage: &FaultingStorage) -> bool {
13402 storage
13403 .does_applied_transaction_exist(&AppliedTransaction {
13404 txid: TEST_TXID.to_string(),
13405 topic: "tm_test".to_string(),
13406 })
13407 .await
13408 .unwrap()
13409 }
13410
13411 #[tokio::test]
13414 async fn clean_submit_is_durable_and_records_applied() {
13415 let (engine, storage) = make_faulting_engine(vec![0]);
13416 let (steak, report) = engine
13417 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13418 .await
13419 .unwrap();
13420 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
13421 assert!(report.is_durable(), "{}", report.summary());
13422 assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
13423 assert!(applied(&storage).await);
13424 assert!(storage
13425 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13426 .await
13427 .unwrap()
13428 .is_some());
13429 }
13430
13431 #[tokio::test]
13436 async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
13437 let (engine, storage) = make_faulting_engine(vec![0]);
13438 storage.fail_insert_output.set(true);
13439 let (steak, report) = engine
13440 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13441 .await
13442 .unwrap();
13443 assert_eq!(
13444 steak["tm_test"].outputs_to_admit,
13445 vec![0],
13446 "the admission DECISION is unchanged by a write fault"
13447 );
13448 assert!(!report.is_durable());
13449 assert_eq!(report.faults.len(), 1, "{}", report.summary());
13450 assert_eq!(report.faults[0].topic, "tm_test");
13451 assert_eq!(report.faults[0].site, "insert_output");
13452 assert!(report.faults[0].error.contains("D1_ERROR"));
13453 assert!(report.applied_topics.is_empty());
13454 assert!(
13455 !applied(&storage).await,
13456 "a faulted topic must not be recorded as applied — the replay would be a dupe"
13457 );
13458 assert!(storage
13459 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13460 .await
13461 .unwrap()
13462 .is_none());
13463 }
13464
13465 #[tokio::test]
13469 async fn replay_after_a_fault_reapplies_and_records_applied() {
13470 let (engine, storage) = make_faulting_engine(vec![0]);
13471 storage.fail_insert_output.set(true);
13472 let (_, first) = engine
13473 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13474 .await
13475 .unwrap();
13476 assert!(!first.is_durable());
13477
13478 storage.fail_insert_output.set(false);
13479 let (steak, replay) = engine
13480 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13481 .await
13482 .unwrap();
13483 assert_eq!(
13484 steak["tm_test"].outputs_to_admit,
13485 vec![0],
13486 "re-validated, not deduped"
13487 );
13488 assert!(replay.is_durable(), "{}", replay.summary());
13489 assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
13490 assert!(applied(&storage).await);
13491 assert!(storage
13492 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13493 .await
13494 .unwrap()
13495 .is_some());
13496
13497 let (steak, third) = engine
13498 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13499 .await
13500 .unwrap();
13501 assert!(
13502 steak["tm_test"].outputs_to_admit.is_empty(),
13503 "now a genuine dupe: nothing admitted"
13504 );
13505 assert!(third.is_durable() && third.applied_topics.is_empty());
13506 }
13507
13508 #[tokio::test]
13514 async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
13515 let (engine, storage) = make_faulting_engine(vec![0]);
13516 let tx = Transaction::from_beef(&test_beef(), None).unwrap();
13519 let source_txid = tx.inputs[0].get_source_txid().unwrap();
13520 let source_vout = tx.inputs[0].source_output_index;
13521 storage
13522 .insert_output(&Output {
13523 txid: source_txid.clone(),
13524 output_index: source_vout,
13525 output_script: vec![0x51],
13526 satoshis: 1,
13527 topic: "tm_test".to_string(),
13528 spent: false,
13529 outputs_consumed: vec![],
13530 consumed_by: vec![],
13531 beef: None,
13532 block_height: None,
13533 score: Some(1.0),
13534 })
13535 .await
13536 .unwrap();
13537
13538 storage.fail_find_output.set(true);
13539 let (_, faulted) = engine
13540 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13541 .await
13542 .unwrap();
13543 assert!(!faulted.is_durable());
13544 assert!(
13545 faulted.faults.iter().any(|f| f.site == "find_output"),
13546 "{}",
13547 faulted.summary()
13548 );
13549 assert!(
13550 !applied(&storage).await,
13551 "unrecorded: the replay must re-read"
13552 );
13553 storage.fail_find_output.set(false);
13554 assert!(
13555 storage
13556 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13557 .await
13558 .unwrap()
13559 .is_some(),
13560 "the coin the faulted pass could not see is still there, untouched"
13561 );
13562
13563 let (_, replay) = engine
13564 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13565 .await
13566 .unwrap();
13567 assert!(replay.is_durable(), "{}", replay.summary());
13568 assert!(applied(&storage).await);
13569 assert!(
13572 storage
13573 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13574 .await
13575 .unwrap()
13576 .is_none(),
13577 "the healthy replay consumed the coin the faulted pass missed"
13578 );
13579 }
13580
13581 #[tokio::test]
13588 async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
13589 use std::cell::RefCell;
13590 use std::rc::Rc;
13591
13592 struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
13593 #[async_trait(?Send)]
13594 impl LookupServiceTrait for CountingLs {
13595 fn admission_mode(&self) -> AdmissionMode {
13596 self.1
13597 }
13598 fn spend_notification_mode(&self) -> SpendNotificationMode {
13599 SpendNotificationMode::None
13600 }
13601 async fn output_admitted_by_topic(
13602 &self,
13603 payload: &OutputAdmittedByTopic,
13604 ) -> Result<(), LookupServiceError> {
13605 let rec = match payload {
13606 OutputAdmittedByTopic::LockingScript {
13607 txid,
13608 output_index,
13609 topic,
13610 ..
13611 } => (txid.clone(), *output_index, topic.clone()),
13612 OutputAdmittedByTopic::WholeTx {
13613 atomic_beef,
13614 output_index,
13615 topic,
13616 ..
13617 } => {
13618 let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
13619 (
13620 beef.atomic_txid
13621 .clone()
13622 .expect("the WholeTx body NAMES its subject"),
13623 *output_index,
13624 topic.clone(),
13625 )
13626 }
13627 };
13628 self.0.borrow_mut().push(rec);
13629 Ok(())
13630 }
13631 async fn output_evicted(
13632 &self,
13633 _txid: &str,
13634 _output_index: u32,
13635 ) -> Result<(), LookupServiceError> {
13636 Ok(())
13637 }
13638 async fn lookup(
13639 &self,
13640 _question: &LookupQuestion,
13641 ) -> Result<LookupResult, LookupServiceError> {
13642 Ok(LookupResult::OutputList(Vec::new()))
13643 }
13644 async fn get_documentation(&self) -> String {
13645 "counting".to_string()
13646 }
13647 async fn get_metadata(&self) -> ServiceMetadata {
13648 ServiceMetadata {
13649 name: "counting-ls".to_string(),
13650 ..Default::default()
13651 }
13652 }
13653 }
13654
13655 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13656 managers.insert(
13657 "tm_test".to_string(),
13658 Box::new(MockTopicManager::admitting(vec![0])),
13659 );
13660 let seen = Rc::new(RefCell::new(Vec::new()));
13661 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13662 lookup_services.insert(
13663 "ls_whole".to_string(),
13664 Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
13665 );
13666 lookup_services.insert(
13667 "ls_script".to_string(),
13668 Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
13669 );
13670 let engine = Engine::new(
13671 managers,
13672 lookup_services,
13673 Box::new(MemoryStorage::new()),
13674 None,
13675 EngineConfig::default(),
13676 );
13677
13678 let tagged = test_tagged_beef(vec!["tm_test"]);
13679 engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13680 assert_eq!(seen.borrow().len(), 2, "one admit per service");
13681 let (txid, vout, _) = seen.borrow()[0].clone();
13682 assert_eq!(txid, TEST_TXID);
13683
13684 let (_, report) = engine
13686 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13687 .await
13688 .unwrap();
13689 assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
13690 assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
13691
13692 let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
13694 assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
13695 assert_eq!(r.vouts, vec![vout]);
13696 assert_eq!(seen.borrow().len(), 4);
13697 assert!(seen.borrow()[2..]
13698 .iter()
13699 .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
13700
13701 let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
13703 assert_eq!((r0.outputs, r0.notified), (0, 0));
13704 assert_eq!(seen.borrow().len(), 4);
13705
13706 let r2 = engine
13708 .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
13709 .await
13710 .unwrap();
13711 assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
13712 assert_eq!(seen.borrow().len(), 6);
13713 }
13714
13715 #[tokio::test]
13720 async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
13721 let engine = make_engine(vec![]);
13723 let tagged = test_tagged_beef(vec!["tm_test"]);
13724 let (steak, r1) = engine
13725 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13726 .await
13727 .unwrap();
13728 assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
13729 assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
13730 let (_, r2) = engine
13731 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13732 .await
13733 .unwrap();
13734 assert_eq!(
13735 r2.deduped_topics,
13736 vec!["tm_test".to_string()],
13737 "the phantom row dedups the re-submit"
13738 );
13739 assert!(engine
13740 .forget_phantom_applied(TEST_TXID, "tm_test")
13741 .await
13742 .unwrap());
13743 let (_, r3) = engine
13744 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13745 .await
13746 .unwrap();
13747 assert!(
13748 r3.deduped_topics.is_empty(),
13749 "judged again after the forget"
13750 );
13751 assert!(
13752 !engine
13753 .forget_phantom_applied(TEST_TXID, "tm_other")
13754 .await
13755 .unwrap(),
13756 "no row: nothing to forget"
13757 );
13758
13759 let real = make_engine(vec![0]);
13761 real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13762 assert!(!real
13763 .forget_phantom_applied(TEST_TXID, "tm_test")
13764 .await
13765 .unwrap());
13766 let (_, r4) = real
13767 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13768 .await
13769 .unwrap();
13770 assert_eq!(
13771 r4.deduped_topics,
13772 vec!["tm_test".to_string()],
13773 "a real admission still dedups"
13774 );
13775 }
13776
13777 const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
13783
13784 const WIDE_HEIGHT: u32 = 800_001;
13786
13787 fn wide_leaves(k: u32) -> Vec<String> {
13789 (0..1u32 << k)
13790 .map(|i| {
13791 let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
13792 id.reverse();
13793 hex::encode(id)
13794 })
13795 .collect()
13796 }
13797
13798 fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
13802 let k = txids.len().trailing_zeros() as usize;
13803 let mut levels = vec![Vec::new(); k];
13804 levels[0] = txids
13805 .iter()
13806 .enumerate()
13807 .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
13808 .collect();
13809 let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
13810 let root = bump.compute_root(None).unwrap();
13811 (bump, root)
13812 }
13813
13814 fn w1_bytes(k: u32) -> (Vec<u8>, String) {
13817 let (bump, root) = wide_bump(&wide_leaves(k));
13818 let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
13819 beef.extend_from_slice(&bump.to_binary());
13820 beef.push(0x00);
13821 (beef, root)
13822 }
13823
13824 fn walk_within_bound(
13828 bytes: Vec<u8>,
13829 root: String,
13830 verify_scripts: bool,
13831 subject: String,
13832 what: &str,
13833 ) -> (Result<(), String>, std::time::Duration) {
13834 let (tx, rx) = std::sync::mpsc::channel();
13835 std::thread::spawn(move || {
13836 let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
13837 tracker.add_root(WIDE_HEIGHT, root);
13838 let runtime = tokio::runtime::Builder::new_current_thread()
13839 .build()
13840 .unwrap();
13841 let started = std::time::Instant::now();
13842 let verdict = runtime.block_on(verify_spv_like_the_reference(
13843 Some(&tracker),
13844 verify_scripts,
13845 &bytes,
13846 &subject,
13847 DoorBudget::DEFAULT,
13848 ));
13849 let verdict = match verdict {
13850 Ok(None) => Ok(()),
13851 Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
13852 Err(e) => Err(e.to_string()),
13853 };
13854 let _ = tx.send((verdict, started.elapsed()));
13855 });
13856 rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
13857 panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
13858 })
13859 }
13860
13861 #[test]
13865 fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
13866 for k in [12u32, 13] {
13867 let (bytes, root) = w1_bytes(k);
13868 let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
13869 let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
13870 println!(
13871 "NL-6e W1 {what}: {verdict:?} in {:.3} s",
13872 took.as_secs_f64()
13873 );
13874 assert_eq!(verdict, Ok(()), "{what}");
13875 }
13876 }
13877
13878 fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
13883 use bsv_rs::script::{LockingScript, UnlockingScript};
13884 use bsv_rs::transaction::{TransactionInput, TransactionOutput};
13885 let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
13886 let spending = |txid: &str| {
13887 let mut input = TransactionInput::new(txid.to_string(), 0);
13888 input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
13889 input
13890 };
13891 let parents: Vec<Transaction> = (0..1u32 << k)
13892 .map(|i| {
13893 let mut tx = Transaction::new();
13894 let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
13896 input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
13897 tx.inputs.push(input);
13898 tx.outputs.push(TransactionOutput::new(1, op_true()));
13899 tx
13900 })
13901 .collect();
13902 let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
13903 let (bump, root) = wide_bump(&ids);
13904 let mut beef = Beef::new();
13905 let at = beef.merge_bump(bump);
13906 for parent in &parents {
13907 beef.merge_raw_tx(parent.to_binary(), Some(at));
13908 }
13909 let mut layer = ids;
13910 while layer.len() > 1 {
13911 layer = layer
13912 .chunks(2)
13913 .map(|pair| {
13914 let mut tx = Transaction::new();
13915 tx.inputs.push(spending(&pair[0]));
13916 tx.inputs.push(spending(&pair[1]));
13917 tx.outputs.push(TransactionOutput::new(0, op_true()));
13918 beef.merge_raw_tx(tx.to_binary(), None);
13919 tx.id()
13920 })
13921 .collect();
13922 }
13923 (beef.to_binary(), root, layer.remove(0))
13924 }
13925
13926 #[test]
13927 fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
13928 for k in [12u32, 13] {
13929 let (bytes, root, subject) = proven_fan_in(k);
13930 let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
13931 let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
13932 println!(
13933 "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
13934 took.as_secs_f64()
13935 );
13936 assert_eq!(verdict, Ok(()), "{what}");
13937 }
13938 }
13939 include!("minutes_tests.rs");
13940 include!("memo_tests.rs");
13941 include!("absence_tests.rs");
13942 include!("route_fault_tests.rs");
13943 include!("batch_tests.rs");
13944 include!("page_batch_tests.rs");
13945}