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 scanned: usize,
297 pub proofless: usize,
299 pub completed: usize,
301 pub still_unconfirmed: usize,
304 pub fetch_failed: usize,
307 pub stitch_failed: usize,
309 pub already_proven: usize,
313}
314
315#[derive(Debug, Default, Clone, Serialize)]
325#[serde(rename_all = "camelCase")]
326pub struct SyncAdvertisementsReport {
327 pub to_create: usize,
330 pub to_revoke: usize,
332 #[serde(skip_serializing_if = "Option::is_none")]
334 pub create_error: Option<String>,
335 #[serde(skip_serializing_if = "Option::is_none")]
340 pub submit_error: Option<String>,
341 pub admitted: std::collections::BTreeMap<String, usize>,
345 #[serde(skip_serializing_if = "Option::is_none")]
349 pub lookup_error: Option<String>,
350 pub revoke_skipped: usize,
354 #[serde(skip_serializing_if = "Option::is_none")]
356 pub revoke_error: Option<String>,
357 #[serde(skip_serializing_if = "Option::is_none")]
359 pub revoke_submit_error: Option<String>,
360}
361
362impl SyncAdvertisementsReport {
363 #[must_use]
367 pub fn ok(&self) -> bool {
368 self.create_error.is_none()
369 && self.submit_error.is_none()
370 && self.lookup_error.is_none()
371 && self.revoke_error.is_none()
372 && self.revoke_submit_error.is_none()
373 }
374
375 #[must_use]
377 pub fn admitted_total(&self) -> usize {
378 self.admitted.values().sum()
379 }
380
381 #[must_use]
391 pub fn effective(&self) -> bool {
392 self.ok() && self.admitted_total() >= self.to_create
393 }
394}
395
396struct TopicValidation {
401 topic: String,
402 is_dupe: bool,
403 previous_coins: Vec<u32>,
405 previous_outputs: Vec<Output>,
407 admittance: AdmittanceInstructions,
408 failed: bool,
409 read_fault: Option<(&'static str, String)>,
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct MutationFault {
432 pub topic: String,
434 pub site: &'static str,
443 pub error: String,
445}
446
447#[derive(Debug, Default, Clone, PartialEq, Eq)]
456pub struct MutationReport {
457 pub faults: Vec<MutationFault>,
458 pub applied_topics: Vec<String>,
459 pub deduped_topics: Vec<String>,
464 pub landed_predecessors: Vec<(String, String)>,
470 pub walk_could_not_run: Option<WalkCouldNotRun>,
477 pub landed_walks_could_not_run: Vec<WalkCouldNotRun>,
483}
484
485#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
487pub struct RenotifyReport {
488 pub outputs: u32,
490 pub notified: u32,
492 pub faults: Vec<String>,
494 pub vouts: Vec<u32>,
496}
497
498impl MutationReport {
499 fn fault(&mut self, topic: &str, site: &'static str, error: String) {
500 self.faults.push(MutationFault {
501 topic: topic.to_string(),
502 site,
503 error,
504 });
505 }
506
507 #[must_use]
510 pub fn is_durable(&self) -> bool {
511 self.faults.is_empty()
512 }
513
514 #[must_use]
517 pub fn summary(&self) -> String {
518 self.faults
519 .iter()
520 .map(|f| {
521 let err: String = f.error.chars().take(120).collect();
522 format!("{}/{}: {}", f.topic, f.site, err)
523 })
524 .collect::<Vec<_>>()
525 .join("; ")
526 }
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub struct DoorBudget {
552 pub memory_limit: usize,
557 pub max_work_bytes: u64,
559 pub sig_check_floor: u64,
569 pub max_memory_bytes: u64,
588}
589
590impl DoorBudget {
591 pub const DEFAULT: DoorBudget = DoorBudget {
668 memory_limit: 128 * 1024,
669 max_work_bytes: 64 * 1024 * 1024,
670 sig_check_floor: 64 * 1024,
671 max_memory_bytes: 48 * 1024 * 1024,
672 };
673}
674
675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
677pub enum DoorLimb {
678 Work,
681 Memory,
683}
684
685type Validated = (
689 Vec<TopicValidation>,
690 Steak,
691 Transaction,
692 String,
693 Option<WalkCouldNotRun>,
694);
695
696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700pub enum WalkLimb {
701 OverWork,
703 OverMemory,
705 InterpreterMemory,
708}
709
710impl WalkLimb {
711 #[must_use]
714 pub fn door_limb(self) -> DoorLimb {
715 match self {
716 WalkLimb::OverWork | WalkLimb::InterpreterMemory => DoorLimb::Work,
717 WalkLimb::OverMemory => DoorLimb::Memory,
718 }
719 }
720
721 #[must_use]
723 pub fn as_str(self) -> &'static str {
724 match self {
725 WalkLimb::OverWork => "over_work",
726 WalkLimb::OverMemory => "over_memory",
727 WalkLimb::InterpreterMemory => "interpreter_memory",
728 }
729 }
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
743pub enum WalkBreachPolicy {
744 #[default]
749 NotNow,
750 NetworkAccepted,
756}
757
758#[derive(Debug, Clone, PartialEq, Eq)]
768pub struct WalkCouldNotRun {
769 pub subject_txid: String,
771 pub at_txid: String,
773 pub subject_judged: bool,
775 pub limb: WalkLimb,
777 pub what: String,
779}
780
781#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
784pub struct WalkStats {
785 pub unproven_txs: usize,
787 pub inputs_executed: usize,
789 pub script_bytes: usize,
791 pub hash_ops: usize,
793 pub sig_ops: usize,
796 pub work_bytes: u64,
798 pub memory_bytes: u64,
802 pub subject_judged: bool,
805}
806
807pub(crate) fn script_census(
814 unlocking: &[u8],
815 locking: &[u8],
816 memory_limit: usize,
817) -> Result<(usize, usize, usize), String> {
818 use bsv_rs::script::op::*;
819 let most_keys = (memory_limit / 33).max(1);
820 let mut hash_ops = 0usize;
821 let mut sig_ops = 0usize;
822 for (label, bytes) in [("unlocking", unlocking), ("locking", locking)] {
823 let script = bsv_rs::script::Script::from_binary(bytes)
824 .map_err(|e| format!("{label} script does not parse: {e}"))?;
825 let chunks = script.chunks();
826 for (i, chunk) in chunks.iter().enumerate() {
827 match chunk.op {
828 OP_RIPEMD160 | OP_SHA1 | OP_SHA256 | OP_HASH160 | OP_HASH256 => hash_ops += 1,
829 OP_CHECKSIG | OP_CHECKSIGVERIFY => sig_ops += 1,
830 OP_CHECKMULTISIG | OP_CHECKMULTISIGVERIFY => {
831 let stated = i
832 .checked_sub(1)
833 .map(|j| chunks[j].op)
834 .and_then(|op| match op {
835 OP_1..=OP_16 => Some((op - OP_1 + 1) as usize),
836 _ => None,
837 });
838 sig_ops += stated.unwrap_or(most_keys);
839 }
840 _ => {}
841 }
842 }
843 }
844 Ok((unlocking.len() + locking.len(), hash_ops, sig_ops))
845}
846
847impl Engine {
848 pub const SYNC_RESPONSE_DEFAULT_LIMIT: u64 = 500;
855 pub const SYNC_RESPONSE_MAX_LIMIT: u64 = 1000;
859
860 pub fn clamp_sync_limit(requested: Option<u64>) -> u64 {
867 requested
868 .unwrap_or(Self::SYNC_RESPONSE_DEFAULT_LIMIT)
869 .min(Self::SYNC_RESPONSE_MAX_LIMIT)
870 }
871
872 pub fn new(
874 managers: HashMap<String, Box<dyn TopicManager>>,
875 lookup_services: HashMap<String, Box<dyn LookupService>>,
876 storage: Box<dyn Storage>,
877 advertiser: Option<Box<dyn Advertiser>>,
878 config: EngineConfig,
879 ) -> Self {
880 Self::with_chain_tracker(
881 managers,
882 lookup_services,
883 storage,
884 advertiser,
885 None,
886 None,
887 config,
888 )
889 }
890
891 pub fn with_chain_tracker(
893 managers: HashMap<String, Box<dyn TopicManager>>,
894 lookup_services: HashMap<String, Box<dyn LookupService>>,
895 storage: Box<dyn Storage>,
896 advertiser: Option<Box<dyn Advertiser>>,
897 broadcaster: Option<Box<dyn Broadcaster>>,
898 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
899 config: EngineConfig,
900 ) -> Self {
901 Self::with_all(
902 managers,
903 lookup_services,
904 storage,
905 advertiser,
906 broadcaster,
907 None,
908 chain_tracker,
909 config,
910 )
911 }
912
913 #[allow(clippy::too_many_arguments)]
915 pub fn with_all(
916 managers: HashMap<String, Box<dyn TopicManager>>,
917 lookup_services: HashMap<String, Box<dyn LookupService>>,
918 storage: Box<dyn Storage>,
919 advertiser: Option<Box<dyn Advertiser>>,
920 broadcaster: Option<Box<dyn Broadcaster>>,
921 arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
922 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
923 mut config: EngineConfig,
924 ) -> Self {
925 for manager_name in managers.keys() {
928 if manager_name == "tm_ship" {
929 if matches!(
930 config.sync_configuration.get(manager_name),
931 Some(SyncTarget::Disabled)
932 ) {
933 continue;
934 }
935 let mut combined: HashSet<String> = HashSet::new();
936 if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
937 {
938 combined.extend(peers.iter().cloned());
939 }
940 combined.extend(config.ship_trackers.iter().cloned());
941 if !combined.is_empty() {
942 config.sync_configuration.insert(
943 manager_name.clone(),
944 SyncTarget::Peers(combined.into_iter().collect()),
945 );
946 }
947 } else if manager_name == "tm_slap" {
948 if matches!(
949 config.sync_configuration.get(manager_name),
950 Some(SyncTarget::Disabled)
951 ) {
952 continue;
953 }
954 let mut combined: HashSet<String> = HashSet::new();
955 if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
956 {
957 combined.extend(peers.iter().cloned());
958 }
959 combined.extend(config.slap_trackers.iter().cloned());
960 if !combined.is_empty() {
961 config.sync_configuration.insert(
962 manager_name.clone(),
963 SyncTarget::Peers(combined.into_iter().collect()),
964 );
965 }
966 } else if !config.sync_configuration.contains_key(manager_name) {
967 config
968 .sync_configuration
969 .insert(manager_name.clone(), SyncTarget::Ship);
970 }
971 }
972
973 Self {
974 managers,
975 lookup_services,
976 storage: Box::new(crate::load_storage::MeteredStorage(storage)),
977 advertiser,
978 broadcaster,
979 arc_broadcaster,
980 chain_tracker,
981 gasp_remote_factory: None,
982 ship_lookup_client: None,
983 ship_bootstrap_peers: HashMap::new(),
984 ancestor_fetcher: None,
985 gasp_ancestor_fetcher: None,
986 peer_sync_budget: None,
987 graph_budget: None,
988 graph_budget_limbs: (
989 crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES,
990 crate::gasp::DEFAULT_GRAPH_BUDGET_NODES,
991 ),
992 finalize_submit_budget: None,
993 finalize_gate: crate::gasp::SubmitGate::default(),
994 not_landed: std::cell::RefCell::new(HashSet::new()),
995 landing_guard: None,
996 verify_scripts: true,
997 walk_budget: DoorBudget::DEFAULT,
998 config,
999 }
1000 }
1001
1002 pub fn set_arc_broadcaster(&mut self, arc: Box<dyn ArcBroadcaster>) {
1007 self.arc_broadcaster = Some(arc);
1008 }
1009
1010 pub fn set_gasp_remote_factory(&mut self, factory: Box<dyn crate::gasp::GASPRemoteFactory>) {
1015 self.gasp_remote_factory = Some(factory);
1016 }
1017
1018 pub fn set_ship_lookup_client(
1021 &mut self,
1022 client: Box<dyn crate::ship_lookup::ShipLookupClient>,
1023 ) {
1024 self.ship_lookup_client = Some(client);
1025 }
1026
1027 pub fn set_ship_bootstrap_peers(&mut self, topic: impl Into<String>, peers: Vec<String>) {
1032 self.ship_bootstrap_peers.insert(topic.into(), peers);
1033 }
1034
1035 pub fn set_ancestor_fetcher(&mut self, fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>) {
1045 self.ancestor_fetcher = Some(fetcher);
1046 }
1047
1048 pub fn set_gasp_ancestor_fetcher(
1050 &mut self,
1051 fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>,
1052 ) {
1053 self.gasp_ancestor_fetcher = Some(fetcher);
1054 }
1055
1056 pub fn set_landing_guard(&mut self, guard: LandingGuard) {
1070 self.landing_guard = Some(guard);
1071 }
1072
1073 pub fn set_peer_sync_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1109 self.peer_sync_budget = Some((sleep, budget_ms));
1110 }
1111
1112 pub fn set_graph_budget(&mut self, sleep: SleepFactory, max_calls: u32, budget_ms: u64) {
1171 self.graph_budget = Some((sleep, max_calls, budget_ms));
1172 }
1173
1174 pub fn set_graph_budget_limbs(&mut self, max_bytes_fetched: u64, max_nodes: u32) {
1195 self.graph_budget_limbs = (max_bytes_fetched, max_nodes);
1196 }
1197
1198 pub fn set_finalize_submit_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1219 self.finalize_submit_budget = Some((sleep, budget_ms));
1220 }
1221
1222 pub fn finalize_submit_gate(&self) -> &crate::gasp::SubmitGate {
1230 &self.finalize_gate
1231 }
1232
1233 pub fn set_script_verification(&mut self, enabled: bool) {
1247 self.verify_scripts = enabled;
1248 }
1249
1250 pub fn script_verification(&self) -> bool {
1253 self.verify_scripts
1254 }
1255
1256 pub fn set_walk_budget(&mut self, budget: DoorBudget) {
1268 self.walk_budget = budget;
1269 }
1270
1271 pub fn walk_budget(&self) -> DoorBudget {
1274 self.walk_budget
1275 }
1276
1277 #[allow(
1312 clippy::unused_async,
1313 reason = "the door's callers await it on an engine; 'scripts only' asks no tracker"
1314 )]
1315 pub async fn verify_scripts_only(
1316 &self,
1317 beef_bytes: &[u8],
1318 subject_txid: &str,
1319 ) -> Result<WalkStats, EngineError> {
1320 crate::script_door::walk(beef_bytes, subject_txid, self.walk_budget)
1321 }
1322
1323 #[allow(
1327 clippy::unused_async,
1328 clippy::unused_self,
1329 reason = "the twin of `verify_scripts_only`"
1330 )]
1331 pub async fn verify_scripts_only_under(
1332 &self,
1333 beef_bytes: &[u8],
1334 subject_txid: &str,
1335 budget: DoorBudget,
1336 ) -> Result<WalkStats, EngineError> {
1337 crate::script_door::walk(beef_bytes, subject_txid, budget)
1338 }
1339
1340 pub async fn submit_validate_only(
1357 &self,
1358 tagged_beef: &TaggedBEEF,
1359 mode: SubmitMode,
1360 ) -> Result<Steak, EngineError> {
1361 let (_validations, steak, _tx, _txid, _could_not_run) = self
1365 .run_validation(
1366 tagged_beef,
1367 mode,
1368 &TopicAdmittanceContext::DRY_RUN,
1369 None,
1370 None,
1371 WalkBreachPolicy::NotNow,
1372 )
1373 .await?;
1374 Ok(steak)
1375 }
1376
1377 pub async fn submit(
1391 &self,
1392 tagged_beef: &TaggedBEEF,
1393 mode: SubmitMode,
1394 ) -> Result<Steak, EngineError> {
1395 let (steak, report) = self.submit_with_report(tagged_beef, mode).await?;
1396 if !report.is_durable() {
1397 warn!(
1398 "submit: {} mutation fault(s) — not durable: {}",
1399 report.faults.len(),
1400 report.summary()
1401 );
1402 }
1403 Ok(steak)
1404 }
1405
1406 pub async fn submit_with_report(
1449 &self,
1450 tagged_beef: &TaggedBEEF,
1451 mode: SubmitMode,
1452 ) -> Result<(Steak, MutationReport), EngineError> {
1453 self.submit_with_report_under(tagged_beef, mode, WalkBreachPolicy::NotNow)
1454 .await
1455 }
1456
1457 pub async fn submit_with_report_under(
1464 &self,
1465 tagged_beef: &TaggedBEEF,
1466 mode: SubmitMode,
1467 policy: WalkBreachPolicy,
1468 ) -> Result<(Steak, MutationReport), EngineError> {
1469 self.submit_bounded(tagged_beef, mode, None, false, policy)
1470 .await
1471 }
1472
1473 async fn submit_bounded(
1479 &self,
1480 tagged_beef: &TaggedBEEF,
1481 mode: SubmitMode,
1482 bound: Option<&CallBound>,
1483 finalize: bool,
1484 policy: WalkBreachPolicy,
1485 ) -> Result<(Steak, MutationReport), EngineError> {
1486 let mut question_reads = 0usize;
1490 self.submit_counted(
1491 tagged_beef,
1492 mode,
1493 bound,
1494 finalize,
1495 None,
1496 &mut question_reads,
1497 policy,
1498 )
1499 .await
1500 .map(|(steak, report, _)| (steak, report))
1501 }
1502
1503 #[allow(clippy::too_many_arguments)]
1517 fn submit_counted<'a>(
1518 &'a self,
1519 tagged_beef: &'a TaggedBEEF,
1520 mode: SubmitMode,
1521 bound: Option<&'a CallBound>,
1522 finalize: bool,
1523 carried: Option<&'a HashSet<String>>,
1524 question_reads: &'a mut usize,
1525 policy: WalkBreachPolicy,
1526 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Counted, EngineError>> + 'a>>
1527 {
1528 Box::pin(async move {
1529 self.submit_counted_inner(
1530 tagged_beef,
1531 mode,
1532 bound,
1533 finalize,
1534 carried,
1535 question_reads,
1536 policy,
1537 )
1538 .await
1539 })
1540 }
1541
1542 #[allow(clippy::too_many_arguments)]
1543 async fn submit_counted_inner(
1544 &self,
1545 tagged_beef: &TaggedBEEF,
1546 mode: SubmitMode,
1547 bound: Option<&CallBound>,
1548 finalize: bool,
1549 walked: Option<&HashSet<String>>,
1550 question_reads: &mut usize,
1551 policy: WalkBreachPolicy,
1552 ) -> Result<Counted, EngineError> {
1553 let carried = walked.is_some();
1556 let context = if carried {
1557 TopicAdmittanceContext::DRY_RUN
1558 } else {
1559 TopicAdmittanceContext::default()
1560 };
1561 let (mut validations, mut steak, tx, txid, could_not_run) = self
1562 .run_validation(tagged_beef, mode, &context, bound, walked, policy)
1563 .await?;
1564 if finalize {
1565 let now = (current_timestamp_ms() / 1000.0) as u64;
1566 for validation in &mut validations {
1567 let manager = &self.managers[&validation.topic];
1568 validation.admittance.outputs_to_admit.retain(|index| {
1569 tx.outputs.get(*index as usize).is_none_or(|output| {
1570 manager
1571 .sync_expiry(&output.locking_script)
1572 .is_none_or(|expiry| expiry > now)
1573 })
1574 });
1575 steak.insert(validation.topic.clone(), validation.admittance.clone());
1576 }
1577 }
1578 let mut report = MutationReport {
1579 walk_could_not_run: could_not_run,
1580 ..MutationReport::default()
1581 };
1582 let subject_named_beef: Vec<u8> =
1592 beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
1593 .ok()
1594 .and_then(|mut b| b.to_binary_atomic(&txid).ok())
1595 .unwrap_or_else(|| tagged_beef.beef.clone());
1596
1597 let mut blocked: HashMap<String, String> = HashMap::new();
1601 let landing = Landing {
1604 policy,
1605 subject_walk_ran: report.walk_could_not_run.is_none(),
1606 };
1607 let waits = self
1608 .successors_waiting(
1609 &tx,
1610 &txid,
1611 &subject_named_beef,
1612 &mut validations,
1613 &mut steak,
1614 tagged_beef.off_chain_values.as_deref(),
1615 mode,
1616 bound,
1617 finalize,
1618 !carried,
1619 &mut blocked,
1620 question_reads,
1621 &mut report.landed_predecessors,
1622 landing,
1623 &mut report.landed_walks_could_not_run,
1624 )
1625 .await;
1626 if carried {
1629 for v in &mut validations {
1630 if v.is_dupe || v.failed || v.read_fault.is_some() || waits.contains_key(&v.topic) {
1631 continue;
1632 }
1633 match self
1634 .judge(
1635 &tx,
1636 &v.topic,
1637 &v.previous_coins,
1638 tagged_beef.off_chain_values.as_deref(),
1639 mode,
1640 &TopicAdmittanceContext::default(),
1641 bound,
1642 )
1643 .await
1644 {
1645 Ok(admittance) => v.admittance = admittance,
1646 Err(e) => {
1647 error!("Error validating topic {} during submit: {e}", v.topic);
1648 v.failed = true;
1649 v.previous_coins.clear();
1650 v.previous_outputs.clear();
1651 v.admittance = AdmittanceInstructions::default();
1652 }
1653 }
1654 steak.insert(v.topic.clone(), v.admittance.clone());
1655 }
1656 }
1657
1658 for v in &validations {
1662 if v.is_dupe {
1663 report.deduped_topics.push(v.topic.clone());
1664 continue;
1665 }
1666 if v.failed {
1672 continue;
1673 }
1674
1675 let topic = &v.topic;
1676 let admittance = &v.admittance;
1677 let faults_before = report.faults.len();
1678
1679 if let Some((read, err)) = &v.read_fault {
1685 report.fault(topic, read, err.clone());
1686 self.hold_unapplied(&txid, topic, "a validation read faulted, nothing written");
1687 continue;
1688 }
1689
1690 if let Some(not_now) = waits.get(topic) {
1694 report.fault(topic, "predecessor_not_landed", not_now.clone());
1695 self.hold_unapplied(
1696 &txid,
1697 topic,
1698 &format!("found no coin and its predecessor {not_now}"),
1699 );
1700 continue;
1701 }
1702
1703 let mut outputs_consumed: Vec<Outpoint> = Vec::new();
1705 let mut stale_coins: Vec<&Output> = Vec::new();
1706
1707 for (coin_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1708 let input_index = v.previous_coins[coin_idx];
1709 if admittance.coins_to_retain.contains(&input_index) {
1710 outputs_consumed
1712 .push(Outpoint::new(&prev_output.txid, prev_output.output_index));
1713 } else {
1714 stale_coins.push(prev_output);
1716 }
1717 }
1718
1719 if let Some(steak_entry) = steak.get_mut(topic) {
1721 steak_entry.coins_removed = Some(
1722 stale_coins
1723 .iter()
1724 .enumerate()
1725 .filter_map(|(i, _)| {
1726 v.previous_coins.get(i).copied()
1728 })
1729 .collect(),
1730 );
1731 }
1732
1733 let mut retained_mark_faulted = false;
1741 for (prev_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1742 if let Err(e) = stored(
1743 bound,
1744 self.storage.mark_utxo_as_spent(
1745 &prev_output.txid,
1746 prev_output.output_index,
1747 topic,
1748 ),
1749 )
1750 .await
1751 {
1752 error!("Error marking UTXO as spent: {e}");
1753 report.fault(topic, "mark_utxo_as_spent", e.to_string());
1754 retained_mark_faulted |= admittance
1755 .coins_to_retain
1756 .contains(&v.previous_coins[prev_idx]);
1757 }
1758
1759 let spending_input_idx = v.previous_coins[prev_idx] as usize;
1761
1762 for ls in self.lookup_services.values() {
1764 let payload = match ls.spend_notification_mode() {
1765 SpendNotificationMode::None => OutputSpent::None {
1766 txid: prev_output.txid.clone(),
1767 output_index: prev_output.output_index,
1768 topic: topic.clone(),
1769 },
1770 SpendNotificationMode::Txid => OutputSpent::Txid {
1771 txid: prev_output.txid.clone(),
1772 output_index: prev_output.output_index,
1773 topic: topic.clone(),
1774 spending_txid: txid.clone(),
1775 },
1776 SpendNotificationMode::Script => {
1777 let input = &tx.inputs[spending_input_idx];
1779 let unlocking_script = input
1780 .unlocking_script
1781 .as_ref()
1782 .map(bsv_rs::UnlockingScript::to_binary)
1783 .unwrap_or_default();
1784 OutputSpent::Script {
1785 txid: prev_output.txid.clone(),
1786 output_index: prev_output.output_index,
1787 topic: topic.clone(),
1788 spending_txid: txid.clone(),
1789 input_index: spending_input_idx as u32,
1790 unlocking_script,
1791 sequence_number: input.sequence,
1792 off_chain_values: tagged_beef.off_chain_values.clone(),
1793 }
1794 }
1795 SpendNotificationMode::WholeTx => OutputSpent::WholeTx {
1796 txid: prev_output.txid.clone(),
1797 output_index: prev_output.output_index,
1798 topic: topic.clone(),
1799 spending_atomic_beef: subject_named_beef.clone(),
1800 off_chain_values: tagged_beef.off_chain_values.clone(),
1801 },
1802 };
1803 if let Err(e) = hooked(bound, ls.output_spent(&payload)).await {
1804 error!("Error notifying lookup service of spent output: {e}");
1805 report.fault(topic, "lookup_service.output_spent", e);
1806 }
1807 }
1808 }
1809 if retained_mark_faulted {
1810 self.hold_unapplied(
1811 &txid,
1812 topic,
1813 "the spent mark of a retained coin faulted, nothing inserted",
1814 );
1815 continue;
1816 }
1817
1818 let mut new_utxos: Vec<Outpoint> = Vec::new();
1822 let mut attempted: Vec<u32> = Vec::new();
1823 let mut insert_faulted = false;
1824
1825 for &output_index in &admittance.outputs_to_admit {
1826 let (script_bytes, sats) =
1827 if let Some(tx_output) = tx.outputs.get(output_index as usize) {
1828 (
1829 tx_output.locking_script.to_binary(),
1830 tx_output.get_satoshis(),
1831 )
1832 } else {
1833 (Vec::new(), 0)
1834 };
1835
1836 let output = Output {
1837 txid: txid.clone(),
1838 output_index,
1839 output_script: script_bytes,
1840 satoshis: sats,
1841 topic: topic.clone(),
1842 spent: false,
1843 outputs_consumed: outputs_consumed.clone(),
1844 consumed_by: Vec::new(),
1845 beef: Some(tagged_beef.beef.clone()),
1846 block_height: None,
1847 score: Some(current_timestamp_ms()),
1848 };
1849
1850 attempted.push(output_index);
1851 if let Err(e) = stored(bound, self.storage.insert_output(&output)).await {
1852 error!("Error inserting output for topic {topic}: {e}");
1853 report.fault(topic, "insert_output", e.to_string());
1854 insert_faulted = true;
1855 break;
1856 }
1857
1858 new_utxos.push(Outpoint::new(&txid, output_index));
1859
1860 for ls in self.lookup_services.values() {
1862 let (ls_script, ls_sats) =
1863 if let Some(tx_out) = tx.outputs.get(output_index as usize) {
1864 (tx_out.locking_script.to_binary(), tx_out.get_satoshis())
1865 } else {
1866 (Vec::new(), 0)
1867 };
1868
1869 let payload = match ls.admission_mode() {
1870 AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
1871 txid: txid.clone(),
1872 output_index,
1873 topic: topic.clone(),
1874 satoshis: ls_sats,
1875 locking_script: ls_script,
1876 off_chain_values: tagged_beef.off_chain_values.clone(),
1877 },
1878 AdmissionMode::WholeTx => OutputAdmittedByTopic::WholeTx {
1879 atomic_beef: subject_named_beef.clone(),
1880 output_index,
1881 topic: topic.clone(),
1882 off_chain_values: tagged_beef.off_chain_values.clone(),
1883 },
1884 };
1885
1886 if let Err(e) = hooked(bound, ls.output_admitted_by_topic(&payload)).await {
1887 error!("Error notifying lookup service: {e}");
1888 report.fault(topic, "lookup_service.output_admitted_by_topic", e);
1889 }
1890 }
1891 }
1892
1893 if insert_faulted {
1904 if let Some(bound) = bound {
1905 bound.renew();
1906 }
1907 self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
1908 .await;
1909 self.hold_unapplied(
1910 &txid,
1911 topic,
1912 &format!(
1913 "an insert faulted: {} previous coin(s) kept, nothing of it held",
1914 stale_coins.len()
1915 ),
1916 );
1917 continue;
1918 }
1919
1920 let due_before_the_delete = bound.is_some_and(CallBound::is_due);
1944 let mut delete_faulted = false;
1945 let mut delete_started = false;
1946 for stale in &stale_coins {
1947 let spent_coin_tx = AppliedTransaction {
1948 txid: stale.txid.clone(),
1949 topic: topic.clone(),
1950 };
1951 if let Err(e) = stored(
1952 bound,
1953 self.storage.insert_applied_transaction(&spent_coin_tx),
1954 )
1955 .await
1956 {
1957 error!(
1958 "Error recording {} as applied before its coin is deleted from topic {topic}: {e}",
1959 stale.txid
1960 );
1961 report.fault(topic, "record_spent_coin_applied", e.to_string());
1962 delete_faulted = true;
1963 continue;
1964 }
1965 self.not_landed
1966 .borrow_mut()
1967 .remove(&(stale.txid.clone(), topic.clone()));
1968 match stored(
1969 bound,
1970 self.storage.find_output(
1971 &stale.txid,
1972 stale.output_index,
1973 Some(topic),
1974 None,
1975 false,
1976 ),
1977 )
1978 .await
1979 {
1980 Ok(Some(stale_output)) => {
1981 delete_started = true;
1982 if let Err(e) = self.delete_utxo_deep(&stale_output, bound).await {
1983 error!("Error deleting stale output for topic {topic}: {e}");
1984 report.fault(topic, "delete_utxo_deep", e.to_string());
1985 delete_faulted = true;
1986 }
1987 }
1988 Ok(None) => {}
1989 Err(e) => {
1990 error!("Error reading stale output for topic {topic}: {e}");
1991 report.fault(topic, "find_output", e.to_string());
1992 delete_faulted = true;
1993 }
1994 }
1995 }
1996
1997 if delete_faulted {
2028 let unanswered = !due_before_the_delete && bound.is_some_and(CallBound::is_due);
2029 if let Some(bound) = bound {
2030 bound.renew();
2031 }
2032 let mut all_held = !unanswered && !delete_started;
2033 for stale in &stale_coins {
2034 if !all_held {
2035 break;
2036 }
2037 let read = stored(
2038 bound,
2039 self.storage.find_output(
2040 &stale.txid,
2041 stale.output_index,
2042 Some(topic),
2043 None,
2044 false,
2045 ),
2046 )
2047 .await;
2048 if !matches!(read, Ok(Some(_))) {
2049 all_held = false;
2050 break;
2051 }
2052 }
2053 if all_held {
2054 self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
2055 .await;
2056 }
2057 self.hold_unapplied(
2058 &txid,
2059 topic,
2060 if all_held {
2061 "no delete of a stale coin was started: the coins are held, its outputs taken out"
2062 } else if unanswered {
2063 "the delete of a stale coin did not answer: nothing undone, its outputs stay"
2064 } else if delete_started {
2065 "the delete of a stale coin faulted: nothing undone, its outputs stay"
2066 } else {
2067 "no delete was started and a stale coin was not read back held: nothing undone, its outputs stay"
2068 },
2069 );
2070 continue;
2071 }
2072
2073 for consumed_outpoint in &outputs_consumed {
2075 let consumed_output = match stored(
2076 bound,
2077 self.storage.find_output(
2078 &consumed_outpoint.txid,
2079 consumed_outpoint.output_index,
2080 Some(topic),
2081 None,
2082 false,
2083 ),
2084 )
2085 .await
2086 {
2087 Ok(Some(o)) => o,
2088 Ok(None) => continue,
2089 Err(e) => {
2090 error!("Error reading consumed output for topic {topic}: {e}");
2091 report.fault(topic, "find_output", e.to_string());
2092 continue;
2093 }
2094 };
2095 {
2096 let mut new_consumed_by = consumed_output.consumed_by.clone();
2097 for new_utxo in &new_utxos {
2098 if !new_consumed_by.iter().any(|c| {
2099 c.txid == new_utxo.txid && c.output_index == new_utxo.output_index
2100 }) {
2101 new_consumed_by.push(new_utxo.clone());
2102 }
2103 }
2104 if let Err(e) = stored(
2105 bound,
2106 self.storage.update_consumed_by(
2107 &consumed_outpoint.txid,
2108 consumed_outpoint.output_index,
2109 topic,
2110 &new_consumed_by,
2111 ),
2112 )
2113 .await
2114 {
2115 error!("Error updating consumedBy: {e}");
2116 report.fault(topic, "update_consumed_by", e.to_string());
2117 }
2118 }
2119 }
2120
2121 if report.faults.len() > faults_before {
2125 self.hold_unapplied(
2126 &txid,
2127 topic,
2128 &format!(
2129 "{} mutation fault(s); a replay of these bytes re-applies",
2130 report.faults.len() - faults_before
2131 ),
2132 );
2133 continue;
2134 }
2135 let tx_record = AppliedTransaction {
2136 txid: txid.clone(),
2137 topic: topic.clone(),
2138 };
2139 match stored(bound, self.storage.insert_applied_transaction(&tx_record)).await {
2140 Ok(()) => {
2141 report.applied_topics.push(topic.clone());
2142 self.not_landed
2143 .borrow_mut()
2144 .remove(&(txid.clone(), topic.clone()));
2145 }
2146 Err(e) => {
2147 error!("Error inserting applied transaction for topic {topic}: {e}");
2148 report.fault(topic, "insert_applied_transaction", e.to_string());
2149 self.not_landed
2150 .borrow_mut()
2151 .insert((txid.clone(), topic.clone()));
2152 }
2153 }
2154 }
2155
2156 Ok((steak, report, blocked))
2157 }
2158
2159 #[allow(clippy::too_many_arguments)]
2211 async fn successors_waiting(
2212 &self,
2213 tx: &Transaction,
2214 txid: &str,
2215 subject_named_beef: &[u8],
2216 validations: &mut [TopicValidation],
2217 steak: &mut Steak,
2218 off_chain_values: Option<&[u8]>,
2219 mode: SubmitMode,
2220 bound: Option<&CallBound>,
2221 finalize: bool,
2222 lands: bool,
2223 blocked: &mut HashMap<String, String>,
2224 question_reads: &mut usize,
2225 landed: &mut Vec<(String, String)>,
2226 landing: Landing,
2227 landed_breaches: &mut Vec<WalkCouldNotRun>,
2228 ) -> HashMap<String, String> {
2229 let mut waits = HashMap::new();
2230 let spent: Vec<(String, u32)> = tx
2231 .inputs
2232 .iter()
2233 .filter_map(|input| {
2234 let source = input.get_source_txid().ok()?;
2235 (!source.is_empty()).then_some((source, input.source_output_index))
2236 })
2237 .collect();
2238 if spent.is_empty() {
2239 return waits;
2240 }
2241 let found_no_coin = |v: &TopicValidation| {
2242 !v.is_dupe && !v.failed && v.read_fault.is_none() && v.previous_outputs.is_empty()
2243 };
2244 for v in validations.iter_mut() {
2245 if !found_no_coin(v) {
2246 continue;
2247 }
2248 let Some(manager) = self.managers.get(&v.topic) else {
2249 continue;
2250 };
2251 let topic = v.topic.clone();
2252 let mut subject_names: Option<HashSet<String>> = None;
2256 loop {
2257 let admits = !v.admittance.outputs_to_admit.is_empty();
2258 if admits && subject_names.is_none() {
2259 subject_names = Some(
2260 self.named_history(manager.as_ref(), subject_named_beef, bound)
2261 .await,
2262 );
2263 }
2264 let remembered = spent.iter().find(|(source, vout)| {
2265 (!admits
2266 || subject_names
2267 .as_ref()
2268 .is_some_and(|named| named.contains(&format!("{source}.{vout}"))))
2269 && self
2270 .not_landed
2271 .borrow()
2272 .contains(&(source.clone(), topic.clone()))
2273 });
2274 if let Some((predecessor, _)) = remembered {
2275 waits.insert(topic.clone(), format!("{predecessor} has not landed"));
2276 break;
2277 }
2278 if finalize {
2279 break;
2280 }
2281 let Some(not_now) = self
2282 .unlanded_predecessor(
2283 tx,
2284 txid,
2285 subject_named_beef,
2286 &topic,
2287 mode,
2288 admits,
2289 &mut subject_names,
2290 question_reads,
2291 bound,
2292 )
2293 .await
2294 else {
2295 break;
2296 };
2297 let Some(carried) = not_now.carried else {
2298 waits.insert(topic.clone(), not_now.why);
2299 break;
2300 };
2301 if !lands {
2302 blocked.insert(topic.clone(), carried);
2303 waits.insert(topic.clone(), not_now.why);
2304 break;
2305 }
2306 if !self.lands_without_values(&topic) {
2307 waits.insert(
2308 topic.clone(),
2309 format!(
2310 "{}; 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",
2311 not_now.why
2312 ),
2313 );
2314 break;
2315 }
2316 let rejudge = 1 + spent.len();
2318 if *question_reads + rejudge > PREDECESSOR_READS_PER_SUBMIT {
2319 waits.insert(
2320 topic.clone(),
2321 format!(
2322 "{}; its landing and the re-judgement after it do not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads",
2323 not_now.why
2324 ),
2325 );
2326 break;
2327 }
2328 *question_reads += rejudge;
2329 match self
2330 .land_carried(
2331 subject_named_beef,
2332 txid,
2333 &carried,
2334 &topic,
2335 mode,
2336 bound,
2337 question_reads,
2338 landed,
2339 landing,
2340 landed_breaches,
2341 )
2342 .await
2343 {
2344 Ok(()) => {
2345 *v = self
2346 .validate_topic(
2347 tx,
2348 txid,
2349 &topic,
2350 off_chain_values,
2351 mode,
2352 &TopicAdmittanceContext::default(),
2353 bound,
2354 )
2355 .await;
2356 steak.insert(topic.clone(), v.admittance.clone());
2357 if !found_no_coin(v) {
2358 break;
2359 }
2360 }
2361 Err(did_not_land) => {
2362 *question_reads -= rejudge;
2364 waits.insert(
2365 topic.clone(),
2366 format!(
2367 "{}; submitted first from the BEEF, it did not land ({did_not_land})",
2368 not_now.why
2369 ),
2370 );
2371 break;
2372 }
2373 }
2374 }
2375 if !finalize
2384 && waits.contains_key(&topic)
2385 && *question_reads < PREDECESSOR_READS_PER_SUBMIT
2386 {
2387 *question_reads += 1;
2388 if let Ok(outputs) = stored(
2389 bound,
2390 self.storage.find_outputs_for_transaction(txid, false),
2391 )
2392 .await
2393 {
2394 if outputs.iter().any(|o| o.topic == topic) {
2395 info!("topic {topic}: {txid} holds its outputs already; its replay finishes it");
2396 waits.remove(&topic);
2397 blocked.remove(&topic);
2398 }
2399 }
2400 }
2401 }
2402 waits
2403 }
2404
2405 #[allow(clippy::too_many_arguments)]
2438 async fn land_carried(
2439 &self,
2440 beef_bytes: &[u8],
2441 subject: &str,
2442 predecessor: &str,
2443 topic: &str,
2444 mode: SubmitMode,
2445 bound: Option<&CallBound>,
2446 question_reads: &mut usize,
2447 landed: &mut Vec<(String, String)>,
2448 landing: Landing,
2449 landed_breaches: &mut Vec<WalkCouldNotRun>,
2450 ) -> Result<(), String> {
2451 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
2452 .map_err(|e| format!("the BEEF could not be read again ({e})"))?;
2453 let mode = match mode {
2454 SubmitMode::CurrentTx => SubmitMode::HistoricalTx,
2455 other => other,
2456 };
2457 let walks = mode != SubmitMode::HistoricalTxNoSpv;
2458 let mut walked: HashSet<String> = if walks && landing.subject_walk_ran {
2463 walk_cover(&beef, subject)
2464 } else {
2465 HashSet::new()
2466 };
2467 let mut stack = vec![predecessor.to_string()];
2468 let mut guarded: HashSet<String> = HashSet::new();
2469 while let Some(next) = stack.last().cloned() {
2470 let body = beef
2471 .find_atomic_transaction(&next)
2472 .ok_or_else(|| format!("{next}: its body is not in the BEEF"))?;
2473 if let Some(guard) = &self.landing_guard {
2476 if guarded.insert(next.clone()) {
2477 bounded(bound, guard(&next, topic))
2478 .await
2479 .and_then(|answer| answer)
2480 .map_err(|why| format!("{next}: refused by the landing guard ({why})"))?;
2481 }
2482 }
2483 let cost = 1 + 2 * body.inputs.len();
2486 if *question_reads + cost > PREDECESSOR_READS_PER_SUBMIT {
2487 return Err(format!(
2488 "{next}: its submit does not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2489 ));
2490 }
2491 *question_reads += cost;
2492 let atomic = body
2493 .to_atomic_beef(true)
2494 .map_err(|e| format!("{next}: its BEEF could not be built ({e})"))?;
2495 let tagged = TaggedBEEF::new(atomic, vec![topic.to_string()]);
2496 let (_, report, blocked) = match self
2497 .submit_counted(
2498 &tagged,
2499 mode,
2500 bound,
2501 false,
2502 Some(&walked),
2503 question_reads,
2504 landing.policy,
2505 )
2506 .await
2507 {
2508 Ok(counted) => counted,
2509 Err(EngineError::WalkCouldNotRun(stop)) => {
2512 let why = format!("{next}: {}", EngineError::WalkCouldNotRun(stop.clone()));
2513 landed_breaches.push(stop);
2514 return Err(why);
2515 }
2516 Err(e) => return Err(format!("{next}: {e}")),
2517 };
2518 if let Some(stop) = &report.walk_could_not_run {
2521 landed_breaches.push(stop.clone());
2522 } else if walks && !walked.contains(&next) {
2523 walked.extend(walk_cover(&beef, &next));
2524 }
2525 let landed_now = report.applied_topics.iter().any(|t| t == topic);
2526 if report.is_durable()
2527 && (landed_now || report.deduped_topics.iter().any(|t| t == topic))
2528 {
2529 info!("topic {topic}: {next} landed first from its successor's BEEF");
2530 if landed_now {
2531 landed.push((next.clone(), topic.to_string()));
2532 }
2533 stack.pop();
2534 continue;
2535 }
2536 match blocked.get(topic) {
2537 Some(under) if !stack.contains(under) => stack.push(under.clone()),
2538 _ if report.is_durable() => return Err(format!("{next}: the manager failed it")),
2539 _ => return Err(format!("{next}: {}", report.summary())),
2540 }
2541 }
2542 Ok(())
2543 }
2544
2545 fn hold_unapplied(&self, txid: &str, topic: &str, why: &str) {
2548 warn!("topic {topic}: {txid}: {why}; applied_transactions NOT recorded (bsv-low #559)");
2549 self.not_landed
2550 .borrow_mut()
2551 .insert((txid.to_string(), topic.to_string()));
2552 }
2553
2554 async fn undo_inserts(
2562 &self,
2563 txid: &str,
2564 topic: &str,
2565 output_indices: &[u32],
2566 bound: Option<&CallBound>,
2567 report: &mut MutationReport,
2568 ) {
2569 for &output_index in output_indices {
2570 let row = stored(
2571 bound,
2572 self.storage
2573 .find_output(txid, output_index, Some(topic), None, false),
2574 )
2575 .await;
2576 let undone = match row {
2577 Ok(Some(row)) if !row.spent && row.consumed_by.is_empty() => {
2578 stored(bound, self.storage.delete_output(txid, output_index, topic)).await
2579 }
2580 Ok(_) => Ok(()),
2581 Err(e) => Err(e),
2582 };
2583 if let Err(e) = undone {
2584 error!("Error taking {txid}.{output_index} out of topic {topic} again: {e}");
2585 report.fault(topic, "undo_insert_output", e.to_string());
2586 }
2587 }
2588 }
2589
2590 #[allow(clippy::too_many_arguments)]
2706 async fn unlanded_predecessor(
2707 &self,
2708 tx: &Transaction,
2709 txid: &str,
2710 beef_bytes: &[u8],
2711 topic: &str,
2712 mode: SubmitMode,
2713 admits: bool,
2714 subject_names: &mut Option<HashSet<String>>,
2715 question_reads: &mut usize,
2716 bound: Option<&CallBound>,
2717 ) -> Option<NotNow> {
2718 let manager = self.managers.get(topic)?;
2719 let wait = |why: String| Some(NotNow { why, carried: None });
2720 let starts: Vec<(String, u32)> = tx
2722 .inputs
2723 .iter()
2724 .filter_map(|input| {
2725 let source = input.get_source_txid().ok()?;
2726 (!source.is_empty()).then_some((source, input.source_output_index))
2727 })
2728 .collect();
2729 let mut spent_of: HashMap<String, HashSet<u32>> = HashMap::new();
2732 let mut spenders: HashMap<String, Vec<(String, u32)>> = HashMap::new();
2733 let mut ask: Vec<String> = Vec::new();
2734 for (source, vout) in &starts {
2735 if admits
2736 && subject_names
2737 .as_ref()
2738 .is_some_and(|named| !named.contains(&format!("{source}.{vout}")))
2739 {
2740 continue;
2741 }
2742 spent_of.entry(source.clone()).or_default().insert(*vout);
2743 spenders
2744 .entry(source.clone())
2745 .or_default()
2746 .push((txid.to_string(), *vout));
2747 ask.push(source.clone());
2748 }
2749 if ask.is_empty() {
2752 return None;
2753 }
2754 let direct: HashSet<String> = ask.iter().cloned().collect();
2755 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS).ok()?;
2756 let bodies: HashMap<String, (&Transaction, bool)> = beef
2759 .txs
2760 .iter()
2761 .filter_map(|btx| {
2762 btx.tx()
2763 .map(|body| (btx.txid(), (body, btx.bump_index().is_some())))
2764 })
2765 .collect();
2766 let mut names_over: HashMap<String, HashSet<String>> = HashMap::new();
2768 let mut reads = 0usize;
2770 let mut asked: HashSet<String> = HashSet::new();
2771 while let Some(candidate) = ask.pop() {
2772 if candidate.is_empty() || !asked.insert(candidate.clone()) {
2773 continue;
2774 }
2775 let spent_outputs = spent_of.get(&candidate).cloned().unwrap_or_default();
2776 let body = bodies.get(&candidate).copied();
2779 if body.is_none() {
2780 if !direct.contains(&candidate) {
2781 continue;
2782 }
2783 if subject_names.is_none() {
2784 *subject_names = Some(
2785 self.named_history(manager.as_ref(), beef_bytes, bound)
2786 .await,
2787 );
2788 }
2789 let is_named = subject_names.as_ref().is_some_and(|named| {
2790 spent_outputs
2791 .iter()
2792 .any(|vout| named.contains(&format!("{candidate}.{vout}")))
2793 });
2794 if !is_named {
2795 continue;
2796 }
2797 }
2798 let proven = body.is_some_and(|(_, proven)| proven);
2799 let mut spent = 0usize;
2802 let out_of = |spent: usize| !proven && reads + spent >= PREDECESSOR_READS;
2803 let out_of_reads = || {
2804 wait(format!(
2805 "{candidate} is not known to have landed: the question ran out of its {PREDECESSOR_READS} reads"
2806 ))
2807 };
2808 let out_of_allowance = || {
2811 wait(format!(
2812 "{candidate} is not known to have landed: the question ran out of the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2813 ))
2814 };
2815 let unread = |e: StorageError| {
2816 wait(format!(
2817 "{candidate} is not known to have landed: the store could not say ({e})"
2818 ))
2819 };
2820 let carried = |why: String| {
2821 Some(NotNow {
2822 why,
2823 carried: Some(candidate.clone()),
2824 })
2825 };
2826 let record = AppliedTransaction {
2827 txid: candidate.clone(),
2828 topic: topic.to_string(),
2829 };
2830 if out_of(spent) {
2833 return out_of_reads();
2834 }
2835 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2836 return out_of_allowance();
2837 }
2838 spent += 1;
2839 *question_reads += 1;
2840 match stored(bound, self.storage.does_applied_transaction_exist(&record)).await {
2841 Ok(false) => {}
2842 Ok(true) => continue,
2843 Err(e) => return unread(e),
2844 }
2845 if out_of(spent) {
2846 return out_of_reads();
2847 }
2848 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2849 return out_of_allowance();
2850 }
2851 spent += 1;
2852 *question_reads += 1;
2853 match stored(
2854 bound,
2855 self.storage.find_outputs_for_transaction(&candidate, false),
2856 )
2857 .await
2858 {
2859 Ok(outputs) if outputs.iter().all(|o| o.topic != topic) => {}
2860 Ok(_) => continue,
2861 Err(e) => return unread(e),
2862 }
2863 let Some((body, _)) = body else {
2866 return wait(format!(
2867 "{candidate} has not landed (the manager names it as history; the BEEF does not carry it)"
2868 ));
2869 };
2870 for input in &body.inputs {
2871 let source = input.get_source_txid().unwrap_or_default();
2872 if source.is_empty() {
2873 continue;
2874 }
2875 if out_of(spent) {
2876 return out_of_reads();
2877 }
2878 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2879 return out_of_allowance();
2880 }
2881 spent += 1;
2882 *question_reads += 1;
2883 let coin = stored(
2884 bound,
2885 self.storage.find_output(
2886 &source,
2887 input.source_output_index,
2888 Some(topic),
2889 None,
2890 false,
2891 ),
2892 )
2893 .await;
2894 match coin {
2895 Ok(Some(_)) => return carried(format!("{candidate} has not landed")),
2896 Ok(None) => {
2899 spent_of
2900 .entry(source.clone())
2901 .or_default()
2902 .insert(input.source_output_index);
2903 spenders
2904 .entry(source.clone())
2905 .or_default()
2906 .push((candidate.clone(), input.source_output_index));
2907 ask.push(source);
2908 }
2909 Err(e) => return unread(e),
2910 }
2911 }
2912 let mut named_outputs: HashSet<u32> = HashSet::new();
2916 for (spender, vout) in spenders.get(&candidate).cloned().unwrap_or_default() {
2917 let names = if spender == txid {
2918 if subject_names.is_none() {
2919 *subject_names = Some(
2920 self.named_history(manager.as_ref(), beef_bytes, bound)
2921 .await,
2922 );
2923 }
2924 subject_names.as_ref()
2925 } else {
2926 if !names_over.contains_key(&spender) {
2927 let names = match beef
2928 .find_atomic_transaction(&spender)
2929 .and_then(|spender| spender.to_atomic_beef(true).ok())
2930 {
2931 Some(spender_beef) => {
2932 self.named_history(manager.as_ref(), &spender_beef, bound)
2933 .await
2934 }
2935 None => HashSet::new(),
2936 };
2937 names_over.insert(spender.clone(), names);
2938 }
2939 names_over.get(&spender)
2940 };
2941 if names.is_some_and(|named| named.contains(&format!("{candidate}.{vout}"))) {
2942 named_outputs.insert(vout);
2943 }
2944 }
2945 if !named_outputs.is_empty() {
2946 let judged = beef
2951 .find_atomic_transaction(&candidate)
2952 .unwrap_or_else(|| body.clone());
2953 let verdict = bounded(
2954 bound,
2955 manager.identify_admissible_outputs(
2956 &judged,
2957 &[],
2958 None,
2959 mode,
2960 &TopicAdmittanceContext::DRY_RUN,
2961 ),
2962 )
2963 .await;
2964 match verdict {
2965 Ok(Ok(admittance)) => {
2966 if admittance
2967 .outputs_to_admit
2968 .iter()
2969 .any(|vout| named_outputs.contains(vout))
2970 {
2971 return carried(format!(
2972 "{candidate} has not landed (the manager names the output spent from it and would admit it with no coin: an opener)"
2973 ));
2974 }
2975 }
2976 Ok(Err(TopicManagerError::NoAdmissibleOutputs(_))) => {}
2977 Ok(Err(e)) => {
2978 return wait(format!(
2979 "{candidate} is not known to have landed: the manager could not judge it ({e})"
2980 ))
2981 }
2982 Err(no_answer) => {
2983 return wait(format!(
2984 "{candidate} is not known to have landed: the manager did not answer ({no_answer})"
2985 ))
2986 }
2987 }
2988 }
2989 if !proven {
2990 reads += spent;
2991 }
2992 }
2993 None
2994 }
2995
2996 async fn named_history(
3002 &self,
3003 manager: &dyn TopicManager,
3004 beef_bytes: &[u8],
3005 bound: Option<&CallBound>,
3006 ) -> HashSet<String> {
3007 match bounded(bound, manager.identify_needed_inputs(beef_bytes, None)).await {
3008 Ok(Ok(named)) => named.iter().map(Outpoint::to_graph_id).collect(),
3009 Ok(Err(e)) => {
3010 warn!("the predecessor question: the manager could not name its inputs: {e}");
3011 HashSet::new()
3012 }
3013 Err(no_answer) => {
3014 warn!("the predecessor question: the manager did not name its inputs: {no_answer}");
3015 HashSet::new()
3016 }
3017 }
3018 }
3019
3020 async fn verify_beef_linear(
3055 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3056 beef_bytes: &[u8],
3057 subject_txid: &str,
3058 trusted: &HashSet<String>,
3059 budget: DoorBudget,
3060 ) -> Result<Option<WalkCouldNotRun>, EngineError> {
3061 let spv =
3063 |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3064
3065 let mut trace = crate::script_door::WalkTrace::default();
3070 match crate::script_door::walk_trusting(
3071 beef_bytes,
3072 subject_txid,
3073 budget,
3074 trusted,
3075 &mut trace,
3076 ) {
3077 Ok(_) => {}
3078 Err(refused @ EngineError::ScriptVerificationFailed { .. }) => return Err(refused),
3080 Err(EngineError::ScriptWalkInconclusive { reason, .. }) => return Err(spv(reason)),
3083 Err(EngineError::ScriptWalkOverBudget {
3092 at_txid,
3093 subject_judged,
3094 limb,
3095 what,
3096 }) => {
3097 let limb = match limb {
3098 DoorLimb::Memory => WalkLimb::OverMemory,
3099 DoorLimb::Work if trace.interpreter_tripped => WalkLimb::InterpreterMemory,
3100 DoorLimb::Work => WalkLimb::OverWork,
3101 };
3102 let reached: Vec<String> = trace
3110 .proven
3111 .iter()
3112 .chain(&trace.proven_sources)
3113 .cloned()
3114 .collect();
3115 Self::check_reached_roots(chain_tracker, beef_bytes, &reached).await?;
3116 return Ok(Some(WalkCouldNotRun {
3117 subject_txid: subject_txid.to_string(),
3118 at_txid,
3119 subject_judged,
3120 limb,
3121 what,
3122 }));
3123 }
3124 Err(other) => return Err(other),
3125 }
3126
3127 Self::check_reached_roots(chain_tracker, beef_bytes, &trace.proven).await?;
3134 Ok(None)
3135 }
3136
3137 async fn check_reached_roots(
3141 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3142 beef_bytes: &[u8],
3143 reached: &[String],
3144 ) -> Result<(), EngineError> {
3145 let spv =
3146 |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3147 if reached.is_empty() {
3148 return Ok(());
3149 }
3150 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
3151 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3152 let proven_in = proven_index(&beef);
3153 let mut checked: HashSet<usize> = HashSet::new();
3154 for txid in reached {
3155 let Some(&at) = proven_in.get(txid.as_str()) else {
3156 return Err(spv(format!("transaction {txid} is not in the BEEF")));
3157 };
3158 if !checked.insert(at) {
3159 continue; }
3161 let mp = &beef.bumps[at];
3162 let root = mp
3163 .compute_root(Some(txid))
3164 .map_err(|e| spv(format!("invalid merkle path for transaction {txid}: {e}")))?;
3165 if let Some(tracker) = chain_tracker {
3166 match tracker
3167 .is_valid_root_for_height(&root, mp.block_height)
3168 .await
3169 {
3170 Ok(true) => {}
3171 Ok(false) => {
3172 return Err(EngineError::SpvError(format!(
3173 "Invalid merkle path for transaction {txid}: root {root} is not valid for block height {}",
3174 mp.block_height
3175 )));
3176 }
3177 Err(e) => {
3178 return Err(EngineError::SpvError(format!(
3179 "Chain tracker error at height {}: {e}",
3180 mp.block_height
3181 )));
3182 }
3183 }
3184 }
3185 }
3186 Ok(())
3187 }
3188
3189 async fn verify_spv_structurally(
3203 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3204 beef_bytes: &[u8],
3205 ) -> Result<(), EngineError> {
3206 let Some(chain_tracker) = chain_tracker else {
3207 return Ok(());
3208 };
3209 let roots = beef_limits::structure_roots(beef_bytes)
3210 .map_err(|r| EngineError::SpvError(format!("BEEF structure refused: {r}")))?;
3211 let mut asked: HashSet<(u64, &str)> = HashSet::new();
3212 for (height, root) in &roots {
3213 if !asked.insert((*height, root.as_str())) {
3214 continue;
3215 }
3216 let Ok(height) = u32::try_from(*height) else {
3217 return Err(EngineError::SpvError(format!(
3218 "Merkle root {root} invalid for block height {height}"
3219 )));
3220 };
3221 match chain_tracker.is_valid_root_for_height(root, height).await {
3222 Ok(true) => {}
3223 Ok(false) => {
3224 return Err(EngineError::SpvError(format!(
3225 "Merkle root {root} invalid for block height {height}"
3226 )));
3227 }
3228 Err(e) => {
3229 return Err(EngineError::SpvError(format!(
3230 "Chain tracker error at height {height}: {e}"
3231 )));
3232 }
3233 }
3234 }
3235 Ok(())
3236 }
3237
3238 #[allow(clippy::too_many_arguments)]
3243 async fn validate_topic(
3244 &self,
3245 tx: &Transaction,
3246 txid: &str,
3247 topic: &str,
3248 off_chain_values: Option<&[u8]>,
3249 mode: SubmitMode,
3250 context: &TopicAdmittanceContext,
3251 bound: Option<&CallBound>,
3252 ) -> TopicValidation {
3253 let tx_record = AppliedTransaction {
3254 txid: txid.to_string(),
3255 topic: topic.to_string(),
3256 };
3257 let is_dupe = match stored(
3267 bound,
3268 self.storage.does_applied_transaction_exist(&tx_record),
3269 )
3270 .await
3271 {
3272 Ok(is_dupe) => is_dupe,
3273 Err(e) => {
3274 error!("Error reading the applied row of {txid} for topic {topic}: {e}");
3275 return TopicValidation {
3276 topic: topic.to_string(),
3277 is_dupe: false,
3278 previous_coins: vec![],
3279 previous_outputs: vec![],
3280 admittance: AdmittanceInstructions::default(),
3281 failed: false,
3282 read_fault: Some(("does_applied_transaction_exist", e.to_string())),
3283 };
3284 }
3285 };
3286
3287 if is_dupe {
3288 return TopicValidation {
3289 topic: topic.to_string(),
3290 is_dupe: true,
3291 previous_coins: vec![],
3292 previous_outputs: vec![],
3293 admittance: AdmittanceInstructions::default(),
3294 failed: false,
3295 read_fault: None,
3296 };
3297 }
3298
3299 let mut previous_coins: Vec<u32> = Vec::new();
3300 let mut previous_outputs: Vec<Output> = Vec::new();
3301 let mut read_fault: Option<(&'static str, String)> = None;
3302
3303 for (input_idx, input) in tx.inputs.iter().enumerate() {
3304 let source_txid = input.get_source_txid().unwrap_or_default();
3305 if source_txid.is_empty() {
3306 continue;
3307 }
3308 match stored(
3309 bound,
3310 self.storage.find_output(
3311 &source_txid,
3312 input.source_output_index,
3313 Some(topic),
3314 None,
3315 false,
3316 ),
3317 )
3318 .await
3319 {
3320 Ok(Some(prev_output)) => {
3321 previous_coins.push(input_idx as u32);
3322 previous_outputs.push(prev_output);
3323 }
3324 Ok(None) => {}
3325 Err(e) => {
3326 error!(
3332 "Error reading previous output {source_txid}:{} for topic {topic}: {e}",
3333 input.source_output_index
3334 );
3335 if read_fault.is_none() {
3336 read_fault = Some(("find_output", e.to_string()));
3337 }
3338 }
3339 }
3340 }
3341
3342 let judged = self
3343 .judge(
3344 tx,
3345 topic,
3346 &previous_coins,
3347 off_chain_values,
3348 mode,
3349 context,
3350 bound,
3351 )
3352 .await;
3353 match judged {
3354 Ok(admittance) => TopicValidation {
3355 topic: topic.to_string(),
3356 is_dupe: false,
3357 previous_coins,
3358 previous_outputs,
3359 admittance,
3360 failed: false,
3361 read_fault: read_fault.clone(),
3362 },
3363 Err(e) => {
3364 error!("Error validating topic {topic} during submit: {e}");
3365 TopicValidation {
3366 topic: topic.to_string(),
3367 is_dupe: false,
3368 previous_coins: vec![],
3369 previous_outputs: vec![],
3370 admittance: AdmittanceInstructions::default(),
3371 failed: true,
3372 read_fault,
3373 }
3374 }
3375 }
3376 }
3377
3378 #[allow(clippy::too_many_arguments)]
3383 async fn judge(
3384 &self,
3385 tx: &Transaction,
3386 topic: &str,
3387 previous_coins: &[u32],
3388 off_chain_values: Option<&[u8]>,
3389 mode: SubmitMode,
3390 context: &TopicAdmittanceContext,
3391 bound: Option<&CallBound>,
3392 ) -> Result<AdmittanceInstructions, TopicManagerError> {
3393 let manager = &self.managers[topic];
3394 let previous_coin_bytes = previous_coins
3395 .iter()
3396 .flat_map(|i| i.to_le_bytes())
3397 .collect::<Vec<u8>>();
3398 bounded(
3401 bound,
3402 manager.identify_admissible_outputs(
3403 tx,
3407 &previous_coin_bytes,
3408 off_chain_values,
3409 mode,
3410 context,
3413 ),
3414 )
3415 .await
3416 .map_err(TopicManagerError::Other)
3417 .and_then(|judged| judged)
3418 }
3419
3420 fn lands_without_values(&self, topic: &str) -> bool {
3424 self.managers
3425 .get(topic)
3426 .is_some_and(|manager| !manager.reads_off_chain_values())
3427 && self
3428 .lookup_services
3429 .values()
3430 .all(|ls| !ls.reads_off_chain_values(topic))
3431 }
3432
3433 async fn run_validation(
3438 &self,
3439 tagged_beef: &TaggedBEEF,
3440 mode: SubmitMode,
3441 context: &TopicAdmittanceContext,
3442 bound: Option<&CallBound>,
3443 walked: Option<&HashSet<String>>,
3444 policy: WalkBreachPolicy,
3445 ) -> Result<Validated, EngineError> {
3446 for topic in &tagged_beef.topics {
3448 if !self.managers.contains_key(topic) {
3449 return Err(EngineError::UnsupportedTopic(topic.clone()));
3450 }
3451 }
3452
3453 let subject = {
3459 let mut b =
3460 beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
3461 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3462 crate::subject::subject_txid_of(&mut b)
3463 };
3464 let tx = beef_limits::transaction_from_beef(
3465 &tagged_beef.beef,
3466 subject.as_deref(),
3467 &beef_limits::ENGINE_BEEF_LIMITS,
3468 )
3469 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3470 let txid = tx.id();
3471
3472 let mut could_not_run = None;
3495 if mode != SubmitMode::HistoricalTxNoSpv {
3496 match walked {
3497 Some(walked) if walked.contains(&txid) => {}
3498 trusted => {
3499 could_not_run = verify_spv_trusting(
3500 self.chain_tracker.as_deref(),
3501 self.verify_scripts,
3502 &tagged_beef.beef,
3503 &txid,
3504 trusted.unwrap_or(&HashSet::new()),
3505 self.walk_budget,
3506 )
3507 .await?;
3508 }
3509 }
3510 }
3511 if let Some(stop) = could_not_run.take() {
3512 match policy {
3513 WalkBreachPolicy::NotNow => {
3514 warn!(
3515 "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)",
3516 stop.subject_txid,
3517 stop.limb.as_str(),
3518 stop.at_txid,
3519 stop.subject_judged,
3520 stop.what
3521 );
3522 return Err(EngineError::WalkCouldNotRun(stop));
3523 }
3524 WalkBreachPolicy::NetworkAccepted => {
3525 warn!(
3526 "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)",
3527 stop.subject_txid,
3528 stop.limb.as_str(),
3529 stop.at_txid,
3530 stop.subject_judged,
3531 stop.what
3532 );
3533 could_not_run = Some(stop);
3534 }
3535 }
3536 }
3537
3538 let mut steak = Steak::new();
3539
3540 let mut validations = Vec::new();
3544
3545 for topic in &tagged_beef.topics {
3546 validations.push(
3547 self.validate_topic(
3548 &tx,
3549 &txid,
3550 topic,
3551 tagged_beef.off_chain_values.as_deref(),
3552 mode,
3553 context,
3554 bound,
3555 )
3556 .await,
3557 );
3558 }
3559
3560 for v in &validations {
3562 steak.insert(v.topic.clone(), v.admittance.clone());
3563 }
3564
3565 if mode == SubmitMode::CurrentTx {
3569 let all_topics_dupe = !validations.is_empty() && validations.iter().all(|v| v.is_dupe);
3584 if let Some(ref arc) = self.arc_broadcaster {
3585 if all_topics_dupe {
3586 info!(
3587 "Skipping ARC broadcast — tx already applied to every topic (re-present)"
3588 );
3589 } else if tx.merkle_path.is_none() {
3590 let raw_tx_hex = tx.to_hex();
3591 match arc.broadcast(&raw_tx_hex).await {
3592 Ok(arc_txid) => {
3593 info!("ARC broadcast succeeded: txid={arc_txid}");
3594 }
3595 Err(e) => {
3596 error!("ARC broadcast failed (non-fatal): {e}");
3597 }
3598 }
3599 } else {
3600 info!("Skipping ARC broadcast — tx already has merkle proof");
3601 }
3602 }
3603
3604 if let Some(ref broadcaster) = self.broadcaster {
3606 let relevant_topics: Vec<String> = validations
3607 .iter()
3608 .filter(|v| {
3609 !v.is_dupe && !v.failed && !v.admittance.outputs_to_admit.is_empty()
3610 })
3611 .map(|v| v.topic.clone())
3612 .collect();
3613
3614 if !relevant_topics.is_empty() {
3615 if let Some(ship_ls) = self.lookup_services.get("ls_ship") {
3616 for topic in &relevant_topics {
3617 let question = LookupQuestion::new(
3618 "ls_ship",
3619 serde_json::json!({ "topics": [topic] }),
3620 );
3621 match ship_ls.lookup(&question).await {
3622 Ok(LookupResult::OutputList(refs)) => {
3623 for reference in &refs {
3624 if let Ok(Some(output)) = self
3625 .storage
3626 .find_output(
3627 &reference.txid,
3628 reference.output_index,
3629 None,
3630 None,
3631 false,
3632 )
3633 .await
3634 {
3635 if let Some(domain) =
3636 parse_ship_domain_from_script(&output.output_script)
3637 {
3638 if let Some(ref our_url) = self.config.hosting_url {
3639 if domain.trim_end_matches('/')
3640 == our_url.trim_end_matches('/')
3641 {
3642 continue;
3643 }
3644 }
3645 if let Err(e) = broadcaster
3646 .broadcast_to_host(&domain, tagged_beef)
3647 .await
3648 {
3649 error!(
3650 "SHIP propagation to {domain} failed for topic {topic}: {e}"
3651 );
3652 }
3653 }
3654 }
3655 }
3656 }
3657 Ok(LookupResult::Answer(_)) => {
3658 error!(
3662 "SHIP lookup for topic {topic} returned a pre-formed \
3663 LookupAnswer; expected OutputList. Skipping fanout."
3664 );
3665 }
3666 Err(e) => {
3667 error!("SHIP lookup for topic {topic} failed: {e}");
3668 }
3669 }
3670 }
3671 }
3672 }
3673 }
3674 }
3675
3676 Ok((validations, steak, tx, txid, could_not_run))
3677 }
3678
3679 pub async fn lookup(
3691 &self,
3692 question: &LookupQuestion,
3693 history_selector: Option<HistorySelector>,
3694 ) -> Result<LookupAnswer, EngineError> {
3695 Ok(self.lookup_with_txids(question, history_selector).await?.0)
3696 }
3697
3698 pub async fn lookup_with_txids(
3707 &self,
3708 question: &LookupQuestion,
3709 history_selector: Option<HistorySelector>,
3710 ) -> Result<(LookupAnswer, Vec<String>), EngineError> {
3711 let service = self
3712 .lookup_services
3713 .get(&question.service)
3714 .ok_or_else(|| EngineError::LookupServiceNotFound(question.service.clone()))?;
3715
3716 let result = service.lookup(question).await.map_err(|e| match e {
3721 LookupServiceError::InvalidQuery(m) | LookupServiceError::Unsupported(m) => {
3722 EngineError::InvalidQuery(m)
3723 }
3724 other => EngineError::LookupFailed(other.to_string()),
3725 })?;
3726
3727 let refs = match result {
3736 LookupResult::OutputList(refs) => refs,
3737 LookupResult::Answer(answer) => return Ok((answer, Vec::new())),
3738 };
3739
3740 let outpoints: Vec<Outpoint> = refs
3748 .iter()
3749 .map(|r| Outpoint::new(&r.txid, r.output_index))
3750 .collect();
3751 let found = self
3752 .storage
3753 .find_served_outputs(&outpoints)
3754 .await
3755 .map_err(|e| EngineError::StorageError(e.to_string()))?;
3756
3757 let mut outputs = Vec::new();
3758 let mut txids = Vec::new();
3759 for output in found {
3760 let final_output = if history_selector.is_some() {
3762 match self
3763 .get_utxo_history(&output, history_selector.clone())
3764 .await
3765 {
3766 Ok(Some(hydrated)) => hydrated,
3767 _ => output,
3768 }
3769 } else {
3770 output
3771 };
3772
3773 if let Some(beef) = final_output.beef {
3780 if !beef.is_empty() {
3781 txids.push(final_output.txid.clone());
3782 outputs.push(OutputListItem {
3783 beef,
3784 output_index: final_output.output_index,
3785 context: None,
3786 });
3787 }
3788 }
3789 }
3790
3791 Ok((LookupAnswer::OutputList { outputs }, txids))
3792 }
3793
3794 pub async fn get_utxo_history(
3808 &self,
3809 output: &Output,
3810 history_selector: Option<HistorySelector>,
3811 ) -> Result<Option<Output>, EngineError> {
3812 let Some(selector) = history_selector else {
3813 return Ok(Some(output.clone()));
3814 };
3815
3816 if output.beef.is_none() {
3818 return Err(EngineError::Other(
3819 "Output must have associated transaction BEEF!".into(),
3820 ));
3821 }
3822
3823 match self.hydrate_utxo_history(output, &selector, 0).await {
3824 Ok(Some(hydrated_output)) => Ok(Some(hydrated_output)),
3825 Ok(None) => Ok(None),
3826 Err(e) => Err(EngineError::Other(format!(
3827 "Error retrieving UTXO history: {e}"
3828 ))),
3829 }
3830 }
3831
3832 fn hydrate_utxo_history<'a>(
3834 &'a self,
3835 output: &'a Output,
3836 selector: &'a HistorySelector,
3837 current_depth: u32,
3838 ) -> std::pin::Pin<
3839 Box<dyn std::future::Future<Output = Result<Option<Output>, EngineError>> + 'a>,
3840 > {
3841 Box::pin(async move {
3842 let beef_data = output.beef.as_ref().ok_or_else(|| {
3843 EngineError::Other("Output must have associated transaction BEEF!".into())
3844 })?;
3845
3846 let should_traverse = match selector {
3848 HistorySelector::Depth(max_depth) => current_depth <= *max_depth,
3849 };
3850
3851 if !should_traverse {
3852 return Ok(None);
3853 }
3854
3855 let mut acc = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3870 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3871
3872 for consumed in &output.outputs_consumed {
3874 if let Ok(Some(child_output)) = self
3875 .storage
3876 .find_output(&consumed.txid, consumed.output_index, None, None, true)
3877 .await
3878 {
3879 if let Ok(Some(hydrated_child)) = self
3880 .hydrate_utxo_history(&child_output, selector, current_depth + 1)
3881 .await
3882 {
3883 if let Some(ref child_beef) = hydrated_child.beef {
3889 if let Ok(child) = Beef::from_binary(child_beef) {
3890 acc.merge_beef(&child);
3891 }
3892 }
3893 }
3894 }
3895 }
3896
3897 Ok(Some(Output {
3898 beef: Some(acc.to_binary()),
3899 ..output.clone()
3900 }))
3901 })
3902 }
3903
3904 pub async fn list_topic_managers(&self) -> HashMap<String, ServiceMetadata> {
3909 let mut result = HashMap::new();
3910 for (name, manager) in &self.managers {
3911 let meta = manager.get_metadata().await;
3912 result.insert(name.clone(), meta);
3913 }
3914 result
3915 }
3916
3917 pub async fn list_lookup_service_providers(&self) -> HashMap<String, ServiceMetadata> {
3919 let mut result = HashMap::new();
3920 for (name, service) in &self.lookup_services {
3921 let meta = service.get_metadata().await;
3922 result.insert(name.clone(), meta);
3923 }
3924 result
3925 }
3926
3927 pub async fn get_documentation_for_topic_manager(&self, name: &str) -> String {
3929 match self.managers.get(name) {
3930 Some(manager) => manager.get_documentation().await,
3931 None => "No documentation found!".to_string(),
3932 }
3933 }
3934
3935 pub async fn get_documentation_for_lookup_service(&self, name: &str) -> String {
3937 match self.lookup_services.get(name) {
3938 Some(service) => service.get_documentation().await,
3939 None => "No documentation found!".to_string(),
3940 }
3941 }
3942
3943 pub async fn provide_foreign_sync_response(
3961 &self,
3962 request: &GASPInitialRequest,
3963 topic: &str,
3964 ) -> Result<GASPInitialResponse, EngineError> {
3965 let limit = Self::clamp_sync_limit(request.limit);
3966 let outputs = self
3967 .storage
3968 .find_utxos_for_topic(topic, Some(request.since as f64), Some(limit), false)
3969 .await
3970 .map_err(|e| EngineError::StorageError(e.to_string()))?;
3971
3972 Ok(GASPInitialResponse {
3973 utxo_list: outputs
3974 .iter()
3975 .map(|o| GASPOutput {
3976 txid: o.txid.clone(),
3977 output_index: o.output_index,
3978 score: o.score.unwrap_or(0.0),
3979 })
3980 .collect(),
3981 since: request.since,
3982 })
3983 }
3984
3985 pub async fn provide_foreign_gasp_node(
3991 &self,
3992 graph_id: &str,
3993 txid: &str,
3994 output_index: u32,
3995 ) -> Result<GASPNode, EngineError> {
3996 let root_outpoint = Outpoint::from_graph_id(graph_id)
3997 .ok_or_else(|| EngineError::Other(format!("Invalid graphID: {graph_id}")))?;
3998
3999 let root_output = self
4000 .storage
4001 .find_output(
4002 &root_outpoint.txid,
4003 root_outpoint.output_index,
4004 None,
4005 None,
4006 true,
4007 )
4008 .await
4009 .map_err(|e| EngineError::StorageError(e.to_string()))?
4010 .ok_or(EngineError::NodeNotFound)?;
4011
4012 self.hydrate_gasp_node(&root_output, graph_id, txid, output_index)
4013 .await
4014 }
4015
4016 fn hydrate_gasp_node<'a>(
4018 &'a self,
4019 output: &'a Output,
4020 graph_id: &'a str,
4021 txid: &'a str,
4022 output_index: u32,
4023 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GASPNode, EngineError>> + 'a>>
4024 {
4025 Box::pin(async move {
4026 let beef_data = output.beef.as_ref().ok_or(EngineError::NodeNotFound)?;
4027
4028 let root = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
4031 .ok()
4032 .and_then(|mut b| crate::subject::subject_txid_of(&mut b));
4033 let root_tx = beef_limits::transaction_from_beef(
4034 beef_data,
4035 root.as_deref(),
4036 &beef_limits::STORED_BEEF_LIMITS,
4037 )
4038 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
4039
4040 if let Some(node) = Self::search_tx_tree(&root_tx, graph_id, txid, output_index) {
4042 return Ok(node);
4043 }
4044
4045 if let Some(node) = Self::search_beef_for_txid(beef_data, graph_id, txid, output_index)
4048 {
4049 return Ok(node);
4050 }
4051
4052 for consumed in &output.outputs_consumed {
4054 if let Ok(Some(consumed_output)) = self
4055 .storage
4056 .find_output(&consumed.txid, consumed.output_index, None, None, true)
4057 .await
4058 {
4059 if let Ok(node) = self
4060 .hydrate_gasp_node(&consumed_output, graph_id, txid, output_index)
4061 .await
4062 {
4063 return Ok(node);
4064 }
4065 }
4066 }
4067
4068 Err(EngineError::NodeNotFound)
4077 })
4078 }
4079
4080 fn search_tx_tree(
4082 tx: &Transaction,
4083 graph_id: &str,
4084 target_txid: &str,
4085 output_index: u32,
4086 ) -> Option<GASPNode> {
4087 let current_txid = tx.id();
4088
4089 if current_txid == target_txid {
4090 let mut node = GASPNode {
4091 graph_id: graph_id.to_string(),
4092 raw_tx: tx.to_hex(),
4093 output_index,
4094 proof: None,
4095 tx_metadata: None,
4096 output_metadata: None,
4097 inputs: None,
4098 };
4099 if let Some(ref merkle_path) = tx.merkle_path {
4100 node.proof = Some(merkle_path.to_hex());
4101 }
4102 return Some(node);
4103 }
4104
4105 for input in &tx.inputs {
4107 if let Some(ref source_tx) = input.source_transaction {
4108 if let Some(node) =
4109 Self::search_tx_tree(source_tx, graph_id, target_txid, output_index)
4110 {
4111 return Some(node);
4112 }
4113 }
4114 }
4115
4116 None
4117 }
4118
4119 fn search_beef_for_txid(
4123 beef_data: &[u8],
4124 graph_id: &str,
4125 target_txid: &str,
4126 output_index: u32,
4127 ) -> Option<GASPNode> {
4128 if let Ok(tx) = beef_limits::transaction_from_beef(
4130 beef_data,
4131 Some(target_txid),
4132 &beef_limits::STORED_BEEF_LIMITS,
4133 ) {
4134 if tx.id() == target_txid {
4135 let mut node = GASPNode {
4136 graph_id: graph_id.to_string(),
4137 raw_tx: tx.to_hex(),
4138 output_index,
4139 proof: None,
4140 tx_metadata: None,
4141 output_metadata: None,
4142 inputs: None,
4143 };
4144 if let Some(ref merkle_path) = tx.merkle_path {
4145 node.proof = Some(merkle_path.to_hex());
4146 }
4147 return Some(node);
4148 }
4149 }
4150 None
4151 }
4152
4153 pub async fn handle_new_merkle_proof(
4167 &self,
4168 txid: &str,
4169 proof_hex: &str,
4170 block_height: Option<u32>,
4171 ) -> Result<(), EngineError> {
4172 let outputs = self
4173 .storage
4174 .find_outputs_for_transaction(txid, true)
4175 .await
4176 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4177
4178 if outputs.is_empty() {
4179 return Err(EngineError::Other(
4180 "Could not find matching transaction outputs for proof ingest!".into(),
4181 ));
4182 }
4183
4184 let proof = if proof_hex.is_empty() {
4186 None
4187 } else {
4188 Some(
4189 beef_limits::merkle_path_from_hex(proof_hex, beef_limits::PUSH_PROOF_MAX_BYTES)
4190 .map_err(|e| EngineError::Other(format!("Invalid merkle proof hex: {e}")))?,
4191 )
4192 };
4193
4194 for output in &outputs {
4195 if let (Some(ref beef_data), Some(ref proof)) = (&output.beef, &proof) {
4198 if let Some(new_beef) = Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4199 {
4200 let _ = self
4201 .storage
4202 .update_transaction_beef(&output.txid, &new_beef)
4203 .await;
4204 }
4205 }
4206
4207 if let Some(height) = block_height {
4209 let _ = self
4210 .storage
4211 .update_output_block_height(
4212 &output.txid,
4213 output.output_index,
4214 &output.topic,
4215 height,
4216 )
4217 .await;
4218 }
4219
4220 for consuming in &output.consumed_by {
4222 if let Ok(consumed_outputs) = self
4223 .storage
4224 .find_outputs_for_transaction(&consuming.txid, true)
4225 .await
4226 {
4227 for consumed_output in &consumed_outputs {
4228 if let (Some(ref beef_data), Some(ref proof)) =
4234 (&consumed_output.beef, &proof)
4235 {
4236 if let Some(new_beef) =
4237 Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4238 {
4239 let _ = self
4240 .storage
4241 .update_transaction_beef(&consumed_output.txid, &new_beef)
4242 .await;
4243 }
4244 }
4245 }
4246 }
4247 }
4248 }
4249
4250 Ok(())
4251 }
4252
4253 pub async fn complete_missing_proofs(
4289 &self,
4290 limit: u64,
4291 min_age_secs: u64,
4292 ) -> Result<ProofCompletionSummary, EngineError> {
4293 let Some(fetcher) = self.ancestor_fetcher.as_deref() else {
4294 return Ok(ProofCompletionSummary::default());
4295 };
4296 self.complete_missing_proofs_with(fetcher, limit, min_age_secs)
4297 .await
4298 }
4299
4300 pub async fn complete_missing_proofs_with(
4303 &self,
4304 fetcher: &dyn crate::gasp::AncestorFetcher,
4305 limit: u64,
4306 min_age_secs: u64,
4307 ) -> Result<ProofCompletionSummary, EngineError> {
4308 let mut summary = ProofCompletionSummary::default();
4309
4310 let candidates = self
4311 .storage
4312 .find_transactions_for_proof_check(limit, min_age_secs)
4313 .await
4314 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4315 summary.scanned = candidates.len();
4316
4317 for cand in candidates {
4318 let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4322 Ok(b) => b,
4323 Err(e) => {
4324 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4325 continue;
4326 }
4327 };
4328
4329 if let Some(bump_hex) = own_bump
4347 .as_ref()
4348 .map(bsv_rs::transaction::MerklePath::to_hex)
4349 {
4350 if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4351 let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4359 .ok()
4360 .map(|mp| u64::from(mp.block_height));
4361 if let Err(e) = self
4362 .storage
4363 .mark_transaction_proven_at(&cand.txid, height)
4364 .await
4365 {
4366 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4367 } else {
4368 summary.already_proven += 1;
4369 }
4370 continue;
4371 }
4372 warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4373 }
4374 summary.proofless += 1;
4376
4377 let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4382 summary.still_unconfirmed += 1;
4383 continue;
4384 };
4385
4386 let block_height =
4388 beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4389 .ok()
4390 .map(|mp| mp.block_height);
4391
4392 match self
4393 .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4394 .await
4395 {
4396 Ok(()) => summary.completed += 1,
4397 Err(e) => {
4398 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4399 summary.stitch_failed += 1;
4400 }
4401 }
4402 }
4403
4404 info!(
4405 scanned = summary.scanned,
4406 proofless = summary.proofless,
4407 completed = summary.completed,
4408 still_unconfirmed = summary.still_unconfirmed,
4409 fetch_failed = summary.fetch_failed,
4410 stitch_failed = summary.stitch_failed,
4411 already_proven = summary.already_proven,
4412 "[PROOF COMPLETION] pass complete"
4413 );
4414
4415 Ok(summary)
4416 }
4417
4418 pub fn stitch_proof_into_stored_beef(
4459 stored: &[u8],
4460 txid: &str,
4461 proof: &bsv_rs::transaction::MerklePath,
4462 ) -> Option<Vec<u8>> {
4463 let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4464 beef.find_txid(txid)?;
4465 let i = beef.merge_bump(proof.clone());
4466 let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4470 b.path.first().is_some_and(|leaves| {
4471 leaves
4472 .iter()
4473 .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4474 })
4475 });
4476 if proves_subject {
4477 if let Some(tx) = beef.find_txid_mut(txid) {
4478 tx.set_bump_index(Some(i));
4479 }
4480 }
4481 Self::gc_unreferenced_bumps(&mut beef);
4482 Some(beef.to_binary())
4483 }
4484
4485 fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4491 let referenced: std::collections::HashSet<usize> = beef
4492 .txs
4493 .iter()
4494 .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4495 .collect();
4496 if referenced.len() == beef.bumps.len() {
4497 return;
4498 }
4499 let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4500 let mut kept = Vec::with_capacity(referenced.len());
4501 for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4502 if referenced.contains(&old) {
4503 remap[old] = Some(kept.len());
4504 kept.push(bump);
4505 }
4506 }
4507 beef.bumps = kept;
4508 for tx in &mut beef.txs {
4509 if let Some(old) = tx.bump_index() {
4510 tx.set_bump_index(remap[old]);
4511 }
4512 }
4513 }
4514
4515 pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4529 let mut report = SyncAdvertisementsReport::default();
4530
4531 let Some(advertiser) = &self.advertiser else {
4532 return Ok(report); };
4534
4535 let hosting_url = match &self.config.hosting_url {
4536 Some(url) if !url.is_empty() => url.clone(),
4537 _ => return Ok(report), };
4539
4540 let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4542 let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4543
4544 if self.config.suppress_default_sync_advertisements {
4545 configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4546 configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4547 }
4548
4549 let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4554 Ok(ads) => ads,
4555 Err(e) => {
4556 error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4557 report.lookup_error = Some(format!("ship: {e}"));
4558 return Ok(report);
4559 }
4560 };
4561 let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4562 Ok(ads) => ads,
4563 Err(e) => {
4564 error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4565 report.lookup_error = Some(format!("slap: {e}"));
4566 return Ok(report);
4567 }
4568 };
4569
4570 let ships_to_create: Vec<AdvertisementData> = configured_topics
4572 .iter()
4573 .filter(|topic| {
4574 !current_ship
4575 .iter()
4576 .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4577 })
4578 .map(|topic| AdvertisementData {
4579 protocol: Protocol::Ship,
4580 topic_or_service_name: topic.clone(),
4581 })
4582 .collect();
4583
4584 let slaps_to_create: Vec<AdvertisementData> = configured_services
4585 .iter()
4586 .filter(|service| {
4587 !current_slap
4588 .iter()
4589 .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4590 })
4591 .map(|service| AdvertisementData {
4592 protocol: Protocol::Slap,
4593 topic_or_service_name: service.clone(),
4594 })
4595 .collect();
4596
4597 let ships_to_revoke: Vec<Advertisement> = current_ship
4599 .into_iter()
4600 .filter(|a| !configured_topics.contains(&a.topic_or_service))
4601 .collect();
4602
4603 let slaps_to_revoke: Vec<Advertisement> = current_slap
4604 .into_iter()
4605 .filter(|a| !configured_services.contains(&a.topic_or_service))
4606 .collect();
4607
4608 let mut all_to_create = ships_to_create;
4610 all_to_create.extend(slaps_to_create);
4611 report.to_create = all_to_create.len();
4612 if !all_to_create.is_empty() {
4613 match advertiser.create_advertisements(&all_to_create).await {
4614 Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4615 Ok(steak) => {
4616 for (topic, instructions) in &steak {
4617 report
4618 .admitted
4619 .insert(topic.clone(), instructions.outputs_to_admit.len());
4620 }
4621 }
4622 Err(e) => {
4623 error!("Failed to submit new advertisements: {e}");
4624 report.submit_error = Some(e.to_string());
4625 }
4626 },
4627 Err(e) => {
4628 error!("Failed to create advertisements: {e}");
4629 report.create_error = Some(e.to_string());
4630 }
4631 }
4632 }
4633
4634 let mut all_to_revoke = ships_to_revoke;
4636 all_to_revoke.extend(slaps_to_revoke);
4637 report.to_revoke = all_to_revoke.len();
4638 if !all_to_revoke.is_empty() {
4639 match advertiser.revoke_advertisements(&all_to_revoke).await {
4640 Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4646 report.revoke_skipped = all_to_revoke.len();
4647 }
4648 Ok(tagged_beef) => {
4649 if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4650 error!("Failed to submit revocation: {e}");
4651 report.revoke_submit_error = Some(e.to_string());
4652 }
4653 }
4654 Err(e) => {
4655 error!("Failed to revoke advertisements: {e}");
4656 report.revoke_error = Some(e.to_string());
4657 }
4658 }
4659 }
4660
4661 Ok(report)
4662 }
4663
4664 #[allow(dead_code)] fn delete_utxo_deep<'a>(
4678 &'a self,
4679 output: &'a Output,
4680 bound: Option<&'a CallBound>,
4681 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4682 Box::pin(async move {
4683 if output.consumed_by.is_empty() {
4685 stored(
4686 bound,
4687 self.storage
4688 .delete_output(&output.txid, output.output_index, &output.topic),
4689 )
4690 .await
4691 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4692
4693 for ls in self.lookup_services.values() {
4696 let _ = hooked(
4697 bound,
4698 ls.output_no_longer_retained_in_history(
4699 &output.txid,
4700 output.output_index,
4701 &output.topic,
4702 ),
4703 )
4704 .await;
4705 }
4706 }
4707
4708 for consumed in &output.outputs_consumed {
4710 if let Ok(Some(mut stale_output)) = stored(
4711 bound,
4712 self.storage.find_output(
4713 &consumed.txid,
4714 consumed.output_index,
4715 Some(&output.topic),
4716 None,
4717 false,
4718 ),
4719 )
4720 .await
4721 {
4722 stale_output.consumed_by.retain(|c| {
4724 !(c.txid == output.txid && c.output_index == output.output_index)
4725 });
4726
4727 stored(
4728 bound,
4729 self.storage.update_consumed_by(
4730 &consumed.txid,
4731 consumed.output_index,
4732 &output.topic,
4733 &stale_output.consumed_by,
4734 ),
4735 )
4736 .await
4737 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4738
4739 let _ = self.delete_utxo_deep(&stale_output, bound).await;
4741 }
4742 }
4743
4744 Ok(())
4745 }) }
4747
4748 pub async fn renotify_admitted(
4779 &self,
4780 txid: &str,
4781 topic: &str,
4782 ) -> Result<RenotifyReport, EngineError> {
4783 let txid = txid.to_ascii_lowercase();
4784 let outputs = self
4785 .storage
4786 .find_outputs_for_transaction(&txid, true)
4787 .await
4788 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4789 let mut report = RenotifyReport::default();
4790 for output in outputs.iter().filter(|o| o.topic == topic) {
4791 report.outputs += 1;
4792 report.vouts.push(output.output_index);
4793 let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4796 beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4797 .ok()
4798 .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4799 .unwrap_or_else(|| beef.clone())
4800 });
4801 for (name, ls) in &self.lookup_services {
4802 let payload = match ls.admission_mode() {
4803 AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4804 txid: txid.clone(),
4805 output_index: output.output_index,
4806 topic: topic.to_string(),
4807 satoshis: output.satoshis,
4808 locking_script: output.output_script.clone(),
4809 off_chain_values: None,
4810 },
4811 AdmissionMode::WholeTx => {
4812 let Some(atomic_beef) = subject_named.as_ref() else {
4813 report.faults.push(format!(
4814 "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4815 output.output_index
4816 ));
4817 continue;
4818 };
4819 OutputAdmittedByTopic::WholeTx {
4820 atomic_beef: atomic_beef.clone(),
4821 output_index: output.output_index,
4822 topic: topic.to_string(),
4823 off_chain_values: None,
4824 }
4825 }
4826 };
4827 match ls.output_admitted_by_topic(&payload).await {
4828 Ok(()) => report.notified += 1,
4829 Err(e) => report.faults.push(format!("{name}: {e}")),
4830 }
4831 }
4832 }
4833 Ok(report)
4834 }
4835
4836 pub async fn forget_phantom_applied(
4847 &self,
4848 txid: &str,
4849 topic: &str,
4850 ) -> Result<bool, EngineError> {
4851 let txid = txid.to_ascii_lowercase();
4852 let outputs = self
4853 .storage
4854 .find_outputs_for_transaction(&txid, false)
4855 .await
4856 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4857 if outputs.iter().any(|o| o.topic == topic) {
4858 return Ok(false);
4859 }
4860 let rec = AppliedTransaction {
4861 txid: txid.clone(),
4862 topic: topic.to_string(),
4863 };
4864 let exists = self
4865 .storage
4866 .does_applied_transaction_exist(&rec)
4867 .await
4868 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4869 if !exists {
4870 return Ok(false);
4871 }
4872 self.storage
4873 .delete_applied_transaction(&rec)
4874 .await
4875 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4876 Ok(true)
4877 }
4878
4879 pub async fn evict_output(
4880 &self,
4881 txid: &str,
4882 output_index: u32,
4883 topic: Option<&str>,
4884 ) -> Result<(), EngineError> {
4885 if let Some(topic) = topic {
4886 self.storage
4888 .delete_output(txid, output_index, topic)
4889 .await
4890 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4891
4892 for ls in self.lookup_services.values() {
4894 let _ = ls.output_evicted(txid, output_index).await;
4895 }
4896 } else {
4897 let outputs = self
4899 .storage
4900 .find_outputs_for_transaction(txid, false)
4901 .await
4902 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4903
4904 for output in &outputs {
4905 if output.output_index == output_index {
4906 self.storage
4907 .delete_output(txid, output_index, &output.topic)
4908 .await
4909 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4910 }
4911 }
4912
4913 for ls in self.lookup_services.values() {
4915 let _ = ls.output_evicted(txid, output_index).await;
4916 }
4917 }
4918
4919 Ok(())
4920 }
4921
4922 pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
4947 self.start_gasp_sync_filtered(&crate::sync_load::SyncFilter::default())
4948 .await
4949 }
4950
4951 pub async fn start_gasp_sync_filtered(
4953 &self,
4954 filter: &crate::sync_load::SyncFilter,
4955 ) -> Result<GASPSyncResult, EngineError> {
4956 let meter = crate::meter::LoadMeter::default();
4957 self.start_gasp_sync_metered(filter, &meter).await
4958 }
4959
4960 pub async fn start_gasp_sync_metered(
4962 &self,
4963 filter: &crate::sync_load::SyncFilter,
4964 meter: &crate::meter::LoadMeter,
4965 ) -> Result<GASPSyncResult, EngineError> {
4966 let run = self.gasp_run();
4967 meter.scope(self.run_gasp_sync(filter, &run, None)).await
4968 }
4969
4970 fn gasp_run(&self) -> crate::backfill::Run {
4971 crate::backfill::Run::new(
4972 self.gasp_ancestor_fetcher
4973 .as_ref()
4974 .or(self.ancestor_fetcher.as_ref())
4975 .cloned(),
4976 )
4977 }
4978
4979 pub async fn start_gasp_backfill_metered(
4982 &self,
4983 request: &crate::backfill::SyncRequest,
4984 meter: &crate::meter::LoadMeter,
4985 ) -> Result<crate::backfill::BackfillResult, EngineError> {
4986 use crate::backfill::{
4987 BackfillResult, Continuation, Control, BACKFILL_MAX_SLICES, BACKFILL_REQUEST_MS,
4988 };
4989 request.validate().map_err(EngineError::InvalidQuery)?;
4990 let (topic, peer) = request.pair().map_err(EngineError::InvalidQuery)?;
4991 if request.until.is_none() || self.peer_sync_budget.is_none() || self.graph_budget.is_none()
4992 {
4993 return Err(EngineError::InvalidQuery(
4994 "backfill requires until: tip and peer/graph bounds".into(),
4995 ));
4996 }
4997 if self.gasp_remote_factory.is_none()
4998 || !matches!(
4999 self.config.sync_configuration.get(topic),
5000 Some(SyncTarget::Ship | SyncTarget::Peers(_))
5001 )
5002 {
5003 return Err(EngineError::InvalidQuery(
5004 "backfill topic is not enabled for sync".into(),
5005 ));
5006 }
5007 meter
5008 .scope(async {
5009 let initial = self.storage.get_last_interaction(peer, topic).await?;
5010 if let Some(token) = &request.continuation {
5011 let token = Continuation::decode(token).map_err(EngineError::InvalidQuery)?;
5012 if token.cursor > initial {
5013 return Err(EngineError::InvalidQuery(
5014 "continuation is ahead of the durable cursor".into(),
5015 ));
5016 }
5017 }
5018 let filter = request.filter();
5019 let run = self.gasp_run();
5020 let control = Control {
5021 meter: meter.clone(),
5022 budget_ms: request.budget_ms.unwrap_or(0).min(BACKFILL_REQUEST_MS),
5023 };
5024 let mut result = BackfillResult {
5025 sync: GASPSyncResult {
5026 filter: filter.clone(),
5027 ..GASPSyncResult::default()
5028 },
5029 slices: 0,
5030 reached_tip: false,
5031 stop_reason: "slice_allowance".into(),
5032 continuation: None,
5033 };
5034 let mut previous_cursor = initial;
5035 for _ in 0..BACKFILL_MAX_SLICES {
5036 if let Some(reason) = control.stop_reason() {
5037 result.stop_reason = reason.into();
5038 break;
5039 }
5040 let slice = self.run_gasp_sync(&filter, &run, Some(&control)).await?;
5041 let topic_result = slice.topics_synced.get(topic).ok_or_else(|| {
5042 EngineError::InvalidQuery("backfill selection matched no topic".into())
5043 })?;
5044 if !topic_result.peers.iter().any(|p| p == peer) {
5045 return Err(EngineError::InvalidQuery(
5046 "backfill selection matched no peer".into(),
5047 ));
5048 }
5049 let summary = topic_result.peer_results.get(peer).cloned();
5050 let waiting = !topic_result.peers_skipped.is_empty();
5051 result.sync.append_slice(slice);
5052 if let Some(summary) = summary {
5053 result.slices += summary.slices;
5054 if summary.reached_tip {
5055 result.reached_tip = true;
5056 result.stop_reason = "tip".into();
5057 break;
5058 }
5059 if let Some(reason) = control.stop_reason() {
5060 result.stop_reason = reason.into();
5061 break;
5062 }
5063 if summary.cursor <= previous_cursor {
5064 result.stop_reason = "no_progress".into();
5065 break;
5066 }
5067 previous_cursor = summary.cursor;
5068 } else {
5069 result.stop_reason =
5070 if waiting { "peer_wait" } else { "no_progress" }.into();
5071 break;
5072 }
5073 }
5074 let cursor = self.storage.get_last_interaction(peer, topic).await?;
5075 if !result.reached_tip {
5076 result.continuation = Some(Continuation::new(topic, peer, cursor).encode());
5077 }
5078 result.sync.cost = meter.snapshot();
5079 Ok(result)
5080 })
5081 .await
5082 }
5083
5084 async fn run_gasp_sync(
5085 &self,
5086 filter: &crate::sync_load::SyncFilter,
5087 run: &crate::backfill::Run,
5088 control: Option<&crate::backfill::Control>,
5089 ) -> Result<GASPSyncResult, EngineError> {
5090 use crate::gasp::{GASPSync, ENGINE_GASP_SYNC_PAGE_LIMIT};
5091 use crate::gasp_overlay::OverlayGASPStorage;
5092
5093 if self.config.sync_configuration.is_empty() {
5094 info!("[GASP SYNC] No sync configuration — nothing to sync");
5095 return Ok(GASPSyncResult {
5096 cost: crate::meter::SyncCost::default(),
5097 topics_synced: HashMap::new(),
5098 filter: filter.clone(),
5099 topics_filtered: Vec::new(),
5100 });
5101 }
5102
5103 let sync_fetcher = &run.fetcher;
5104 let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
5105 let mut ship_lookup_hosts = None;
5108 let mut topic_order = sync_order(&self.config.sync_configuration);
5109 topic_order.sort_by_key(|(topic, target)| {
5112 !(matches!(target, SyncTarget::Ship)
5113 && self
5114 .ship_bootstrap_peers
5115 .get(topic.as_str())
5116 .is_some_and(|peers| !peers.is_empty()))
5117 });
5118
5119 let mut topics_filtered = Vec::new();
5120 for (topic, target) in topic_order {
5121 if filter
5122 .topics
5123 .as_ref()
5124 .is_some_and(|names| !names.contains(topic))
5125 {
5126 topics_filtered.push(topic.clone());
5127 continue;
5128 }
5129 let cost_before = crate::meter::snapshot();
5130 let configured_peers = matches!(target, SyncTarget::Peers(_));
5133 let (peers, sync_type) = match target {
5134 SyncTarget::Disabled => {
5135 info!("[GASP SYNC] Topic {topic} is disabled — skipping");
5136 continue;
5137 }
5138 SyncTarget::Ship => {
5139 info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
5140 let peers = if let Some(peers) = &filter.peers {
5141 self.ship_peers_excluding_self(peers.clone())
5142 } else {
5143 self.discover_ship_peers(topic, &mut ship_lookup_hosts)
5144 .await
5145 };
5146 info!(
5147 "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
5148 peers.len()
5149 );
5150 (peers, "ship".to_string())
5151 }
5152 SyncTarget::Peers(peer_urls) => {
5153 info!(
5154 "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
5155 peer_urls.len()
5156 );
5157 let peers: Vec<String> = peer_urls
5158 .iter()
5159 .filter(|url| {
5160 if let Some(ref our_url) = self.config.hosting_url {
5161 url.trim_end_matches('/') != our_url.trim_end_matches('/')
5162 } else {
5163 true
5164 }
5165 })
5166 .cloned()
5167 .collect();
5168
5169 info!(
5170 "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
5171 peers.len()
5172 );
5173 (peers, "peers".to_string())
5174 }
5175 };
5176
5177 let (peers, peers_filtered): (Vec<_>, Vec<_>) = peers.into_iter().partition(|peer| {
5178 filter
5179 .peers
5180 .as_ref()
5181 .is_none_or(|names| names.contains(peer))
5182 });
5183 let mut peers_skipped = Vec::new();
5184 let advertisements = self.peer_advertisement_versions(topic).await;
5185 let mut errors = Vec::new();
5186 let mut pruned_inputs: u64 = 0;
5187 let mut discarded_graphs: u64 = 0;
5188 let mut expired_refused: u64 = 0;
5189 let mut roots_proven = 0;
5190 let mut roots_unproven = 0;
5191 let mut cursor_held_by = Vec::new();
5192 let expired = run.expired(topic);
5193 let mut peer_results = HashMap::new();
5194 let mut anchor_walk_held_graphs: u64 = 0;
5195 let mut finalized_graphs: u64 = 0;
5196 let mut deadline_dropped_graphs: u64 = 0;
5197 let mut cursor_moves: Vec<CursorMove> = Vec::new();
5198 let mut deferred_graphs: u64 = 0;
5199 let mut resumed_graphs: u64 = 0;
5200 let mut converged_graphs: u64 = 0;
5201 let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
5202 let mut stalled_graphs: u64 = 0;
5203 let mut held_back_graphs: u64 = 0;
5204 let mut dead_graphs = 0;
5205 let mut expired_swept = 0;
5206 let mut unanchorable_graphs: u64 = 0;
5207 let mut unanchorable_cleared: u64 = 0;
5208 let mut strikes_refused: u64 = 0;
5209 let mut backed_off_graphs: u64 = 0;
5210 let mut waiting_graphs: u64 = 0;
5211 let mut idle_counted: u64 = 0;
5212 let mut root_bounds = Vec::new();
5213 let mut faulted_roots: Vec<crate::gasp::FaultedRoot> = Vec::new();
5214
5215 if let Some(ref factory) = self.gasp_remote_factory {
5217 for peer_url in &peers {
5218 let health_key = crate::gasp::peer_origin(peer_url);
5231 let health = self
5232 .storage
5233 .get_peer_sync_health(&health_key, topic)
5234 .await
5235 .unwrap_or_default();
5236 if crate::gasp::peer_sync_quarantined(&health) {
5237 peers_skipped.push(crate::sync_load::SkippedPeer {
5238 peer: peer_url.clone(),
5239 reason: "quarantine".into(),
5240 retry_after_secs: crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5241 .saturating_sub(health.secs_since_last_attempt.unwrap_or(0)),
5242 });
5243 warn!(
5244 "[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)",
5245 health.consecutive_failures,
5246 health.secs_since_last_attempt,
5247 crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5248 );
5249 continue;
5250 }
5251
5252 let cadence = self
5253 .storage
5254 .get_peer_cadence(&health_key, topic)
5255 .await
5256 .unwrap_or_default();
5257 let advertisement =
5258 advertisements.get(&health_key).cloned().unwrap_or_default();
5259 let reset_cadence = filter.explicit() || cadence.advertisement != advertisement;
5260 if !reset_cadence && cadence.retry_after_secs > 0 {
5261 peers_skipped.push(crate::sync_load::SkippedPeer {
5262 peer: peer_url.clone(),
5263 reason: "no_yield".into(),
5264 retry_after_secs: cadence.retry_after_secs,
5265 });
5266 continue;
5267 }
5268
5269 let peer_cost_before = crate::meter::snapshot();
5270 info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
5271
5272 let last_interaction = self
5274 .storage
5275 .get_last_interaction(peer_url, topic)
5276 .await
5277 .unwrap_or(0);
5278
5279 let sink = crate::gasp_overlay::new_finalized_graph_sink();
5281
5282 let hydration_on = sync_fetcher.is_some();
5291
5292 let mut gasp_storage =
5294 OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5295 .with_peer(peer_url.as_str())
5296 .with_configured_peer(configured_peers)
5297 .with_strict_beef(hydration_on)
5298 .with_script_verification(self.verify_scripts)
5299 .with_walk_budget(self.walk_budget);
5300 if let Some(manager) = self.managers.get(topic) {
5301 gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5302 }
5303 if let Some(tracker) = self.chain_tracker.as_deref() {
5308 gasp_storage = gasp_storage.with_chain_tracker(tracker);
5309 }
5310 let gasp_remote = Box::new(crate::load_remote::MeteredRemote(
5311 factory.create_remote(peer_url, topic),
5312 ));
5313
5314 let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5315 let peer_finalized = std::cell::Cell::new(0u64);
5317 let mut sync = GASPSync::new(
5318 Box::new(gasp_storage),
5319 gasp_remote,
5320 last_interaction,
5321 &log_prefix,
5322 true, )
5324 .with_ancestor_fetcher(sync_fetcher.clone())
5325 .with_expired_outpoints(std::rc::Rc::clone(&expired));
5326 if let Some(control) = control {
5327 let control = control.clone();
5328 sync = sync.with_page_slice(std::rc::Rc::new(move || {
5329 control.stop_reason().is_some()
5330 }));
5331 }
5332 if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5335 let (sleep, budget_ms) =
5336 (sleep.clone(), (*budget_ms).min(crate::meter::ROOT_MAX_MS));
5337 let half_sleep = sleep.clone();
5338 let share_sleep = sleep.clone();
5339 sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5340 max_calls: *max_calls,
5341 max_bytes_fetched: self.graph_budget_limbs.0,
5342 max_nodes: self.graph_budget_limbs.1,
5343 deadline: Box::new(move || sleep(budget_ms)),
5344 half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5345 });
5346 if let Some((_, peer_ms)) = &self.peer_sync_budget {
5349 let share_ms = crate::gasp::resume_share_ms(*peer_ms, budget_ms);
5350 sync = sync.with_resume_share(Box::new(move || share_sleep(share_ms)));
5351 }
5352 }
5353 if self.peer_sync_budget.is_some() {
5358 sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5359 engine: self,
5360 sink: sink.clone(),
5361 peer_url,
5362 topic,
5363 submitted: &peer_finalized,
5364 }));
5365 }
5366
5367 let sync_outcome = match &self.peer_sync_budget {
5377 Some((sleep, budget_ms)) => {
5378 let budget_ms =
5379 control.map_or(*budget_ms, |c| (*budget_ms).min(c.remaining_ms()));
5380 let mut deadline = sleep(budget_ms);
5381 let deadline = std::future::poll_fn(|cx| {
5382 if control.is_some_and(|c| c.stop_reason().is_some())
5383 || std::future::Future::poll(deadline.as_mut(), cx).is_ready()
5384 {
5385 std::task::Poll::Ready(())
5386 } else {
5387 std::task::Poll::Pending
5388 }
5389 });
5390 crate::gasp::race_or_deadline_guarded(
5391 sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5392 deadline,
5393 &self.finalize_gate,
5394 )
5395 .await
5396 }
5397 None => Some(sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)).await),
5398 };
5399
5400 let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5401 pruned_inputs += sync.pruned_inputs();
5407 expired_refused += sync.expired_refused();
5408 roots_proven += sync.roots_proven();
5409 roots_unproven += sync.roots_unproven();
5410 discarded_graphs += sync.discarded_graphs();
5415 anchor_walk_held_graphs += sync.anchor_walk_held();
5416 let ran = !matches!(sync_outcome, Some(Err(_)));
5420 let mut advanced_cursor = false;
5421 match sync_outcome {
5422 None => {
5423 let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5424 let msg = format!(
5425 "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5426 );
5427 warn!("[GASP SYNC] {msg}");
5428 errors.push(msg);
5429
5430 let in_flight = u64::from(sync.graph_in_flight());
5441 deadline_dropped_graphs += in_flight;
5442 sync.defer_in_flight().await;
5445 let completed = sync.completed_cursor();
5446 if completed > last_interaction {
5447 match self
5448 .storage
5449 .update_last_interaction(peer_url, topic, completed)
5450 .await
5451 {
5452 Ok(()) => cursor_moves.push(CursorMove {
5453 peer: peer_url.clone(),
5454 from: last_interaction,
5455 to: completed,
5456 }),
5457 Err(e) => warn!(
5458 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5459 ),
5460 }
5461 }
5462 advanced_cursor = completed > last_interaction;
5482 outcome_success = peer_finalized.get() > 0
5483 || (completed > last_interaction && sync.cursor_yielded())
5484 || sync.deferral_stats().progressed > 0
5485 || sync.did_work();
5486 warn!(
5487 "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5488 peer_finalized.get(),
5489 completed.max(last_interaction)
5490 );
5491 }
5492 Some(Ok(())) => {
5493 let landed = self
5497 .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5498 .await;
5499 let stats = sync.deferral_stats();
5510 if stats.stalled > 0
5511 && stats.progressed == 0
5512 && peer_finalized.get() == 0
5513 && !sync.did_work()
5514 && !(landed
5515 && sync.last_interaction > last_interaction
5516 && sync.cursor_yielded())
5517 {
5518 outcome_success = false;
5519 let msg = format!(
5520 "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5521 stats.stalled
5522 );
5523 warn!("[GASP SYNC] {msg}");
5524 errors.push(msg);
5525 }
5526
5527 advanced_cursor = landed && sync.last_interaction > last_interaction;
5546 if !landed {
5547 warn!(
5548 "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5549 );
5550 } else if sync.last_interaction > last_interaction {
5551 match self
5552 .storage
5553 .update_last_interaction(peer_url, topic, sync.last_interaction)
5554 .await
5555 {
5556 Ok(()) => cursor_moves.push(CursorMove {
5557 peer: peer_url.clone(),
5558 from: last_interaction,
5559 to: sync.last_interaction,
5560 }),
5561 Err(e) => warn!(
5562 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5563 ),
5564 }
5565 }
5566 info!(
5567 "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5568 sync.last_interaction
5569 );
5570 }
5571 Some(Err(e)) => {
5572 let msg = format!("{peer_url}: {e}");
5573 warn!("[GASP SYNC] Sync failed: {msg}");
5574 errors.push(msg);
5575 }
5576 }
5577 let yielded = peer_finalized.get() > 0
5594 || (advanced_cursor && sync.cursor_yielded())
5595 || sync.expired_refused() > 0;
5596 if yielded || sync.did_work() {
5597 outcome_success = true;
5598 }
5599 if ran
5610 && self.graph_budget.is_some()
5611 && (yielded || sync.graphs_attempted() > 0)
5612 {
5613 match self
5614 .storage
5615 .record_peer_sync_yield(&health_key, topic, yielded)
5616 .await
5617 {
5618 Ok(streak)
5619 if !yielded
5620 && crate::gasp::yieldless_sync_failed(&streak)
5621 && outcome_success =>
5622 {
5623 outcome_success = false;
5624 let msg = format!(
5625 "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5626 streak.yieldless_syncs,
5627 streak.secs_since_first.unwrap_or(0),
5628 crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5629 crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5630 );
5631 warn!("[GASP SYNC] {msg}");
5632 errors.push(msg);
5633 }
5634 Ok(_) => {}
5635 Err(e) => warn!(
5636 "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5637 ),
5638 }
5639 }
5640 let quiet = outcome_success
5643 && !yielded
5644 && !sync.did_work()
5645 && sync.faulted_roots().is_empty()
5646 && sync.deferral_stats().stalled == 0;
5647 let quiet_syncs = if quiet && !filter.explicit() {
5648 if reset_cadence {
5649 1
5650 } else {
5651 cadence.quiet_syncs.saturating_add(1)
5652 }
5653 } else {
5654 0
5655 };
5656 let next = crate::sync_load::PeerCadence {
5657 quiet_syncs,
5658 retry_after_secs: crate::sync_load::quiet_retry_secs(quiet_syncs),
5659 advertisement,
5660 };
5661 if let Err(e) = self
5662 .storage
5663 .set_peer_cadence(&health_key, topic, &next)
5664 .await
5665 {
5666 warn!("[GASP SYNC] failed to record cadence for {peer_url}/{topic}: {e}");
5667 }
5668 if !advanced_cursor {
5669 if let Some(outpoint) = sync.cursor_held_by() {
5670 cursor_held_by.push(outpoint);
5671 }
5672 }
5673 finalized_graphs += peer_finalized.get();
5674 let deferral = sync.deferral_stats();
5675 root_bounds.extend(deferral.root_bounds.iter().cloned().map(|mut bound| {
5676 bound.peer.clone_from(peer_url);
5677 bound
5678 }));
5679 deferred_graphs += deferral.deferred;
5680 resumed_graphs += deferral.resumed;
5681 converged_graphs += deferral.converged;
5682 stalled_graphs += deferral.stalled;
5683 held_back_graphs += deferral.held_back;
5684 backed_off_graphs += deferral.backed_off;
5685 waiting_graphs += deferral.waiting;
5686 idle_counted += deferral.idle_counted;
5687 dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5688 peer: peer_url.clone(),
5689 outpoint: d.outpoint,
5690 reason: d.reason.as_str().to_string(),
5691 }));
5692 dead_graphs += sync.dead_recorded();
5694 expired_swept += sync.expired_swept();
5695 unanchorable_graphs += sync.unanchorable_recorded();
5696 unanchorable_cleared += sync.unanchorable_cleared();
5697 strikes_refused += sync.strikes_refused();
5699 faulted_roots.extend(sync.faulted_roots().into_iter().map(|mut f| {
5700 f.peer.clone_from(peer_url);
5701 f
5702 }));
5703
5704 if outcome_success
5724 && !yielded
5725 && self.graph_budget.is_some()
5726 && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5727 {
5728 info!(
5729 "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5730 health.consecutive_failures
5731 );
5732 } else if let Err(e) = self
5733 .storage
5734 .record_peer_sync_outcome(&health_key, topic, outcome_success)
5735 .await
5736 {
5737 warn!(
5738 "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5739 );
5740 }
5741 let cursor = self.storage.get_last_interaction(peer_url, topic).await?;
5742 let cost = crate::meter::snapshot().since(peer_cost_before);
5743 peer_results.insert(
5744 peer_url.clone(),
5745 crate::backfill::PeerSyncResult {
5746 slices: u64::from(sync.page_advanced() || cost.outputs_walked > 0),
5747 pages: sync.pages(),
5748 reached_tip: sync.reached_tip() && cursor >= sync.completed_cursor(),
5749 cursor,
5750 cost,
5751 },
5752 );
5753 }
5754 }
5755
5756 topics_synced.insert(
5757 topic.clone(),
5758 TopicSyncResult {
5759 cost: crate::meter::snapshot().since(cost_before),
5760 root_bounds,
5761 peers_skipped,
5762 peers_filtered,
5763 roots_proven,
5764 roots_unproven,
5765 cursor_held_by,
5766 expired_refused,
5767 peers,
5768 sync_type,
5769 errors,
5770 pruned_inputs,
5771 discarded_graphs,
5772 anchor_walk_held_graphs,
5773 finalized_graphs,
5774 deadline_dropped_graphs,
5775 cursor_moves,
5776 deferred_graphs,
5777 resumed_graphs,
5778 converged_graphs,
5779 dropped_graphs,
5780 stalled_graphs,
5781 held_back_graphs,
5782 backed_off_graphs,
5783 waiting_graphs,
5784 idle_counted,
5785 peer_results,
5786 dead_graphs,
5787 expired_swept,
5788 unanchorable_graphs,
5789 unanchorable_cleared,
5790 strikes_refused,
5791 faulted_roots,
5792 },
5793 );
5794 }
5795
5796 info!(
5797 "[GASP SYNC] Peer discovery complete for {} topic(s)",
5798 topics_synced.len()
5799 );
5800
5801 let expired: u64 = topics_synced.values().map(|r| r.expired_refused).sum();
5802 if expired > 0 {
5803 info!("[GASP SYNC] gaspExpiredRefused={expired}");
5804 }
5805 let unanchorable: u64 = topics_synced.values().map(|r| r.unanchorable_graphs).sum();
5806 if unanchorable > 0 {
5807 info!("[GASP SYNC] gaspUnanchorable={unanchorable}");
5808 }
5809 let refused: u64 = topics_synced.values().map(|r| r.strikes_refused).sum();
5810 if refused > 0 {
5811 info!("[GASP SYNC] gaspStrikesRefused={refused}");
5812 }
5813 Ok(GASPSyncResult {
5814 cost: crate::meter::snapshot(),
5815 topics_synced,
5816 filter: filter.clone(),
5817 topics_filtered,
5818 })
5819 }
5820
5821 async fn submit_finalized_graphs(
5845 &self,
5846 sink: &crate::gasp_overlay::FinalizedGraphSink,
5847 peer_url: &str,
5848 topic: &str,
5849 submitted: &std::cell::Cell<u64>,
5850 ) -> bool {
5851 let finalized: Vec<_> = sink
5852 .lock()
5853 .unwrap_or_else(std::sync::PoisonError::into_inner)
5854 .drain(..)
5855 .collect();
5856
5857 let mut all_landed = true;
5858 for graph in &finalized {
5859 for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5860 self.finalize_gate.stop_if_due().await;
5861 if position == 0 {
5862 submitted.set(submitted.get() + 1);
5863 }
5864 let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5865 let outcome = {
5875 let _writing = self.finalize_gate.write_section();
5876 let bound = self
5877 .finalize_submit_budget
5878 .as_ref()
5879 .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5880 self.submit_bounded(
5881 &tagged,
5882 SubmitMode::HistoricalTxNoSpv,
5883 bound.as_ref(),
5884 true,
5885 WalkBreachPolicy::NotNow,
5886 )
5887 .await
5888 };
5889 let not_landed = match outcome {
5890 Ok((_, report)) => {
5891 let landed = report.is_durable()
5892 && (report.applied_topics.contains(&graph.topic)
5893 || report.deduped_topics.contains(&graph.topic));
5894 (!landed).then(|| {
5895 if report.is_durable() {
5896 "the topic manager failed on it".to_string()
5897 } else {
5898 format!("not durable: {}", report.summary())
5899 }
5900 })
5901 }
5902 Err(e) => Some(e.to_string()),
5903 };
5904 if let Some(why) = not_landed {
5905 warn!(
5906 "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
5907 graph.topic
5908 );
5909 all_landed = false;
5910 break;
5911 }
5912 }
5913 }
5914
5915 if !finalized.is_empty() {
5916 info!(
5917 "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
5918 finalized.len()
5919 );
5920 }
5921 all_landed
5922 }
5923
5924 pub async fn rerequest_held_proofs(
5930 &self,
5931 candidates: &[crate::proof_window::HeldOutput],
5932 progress: &std::cell::Cell<crate::proof_window::PeerProofSummary>,
5933 ) {
5934 use crate::proof_window::{PEER_PROOF_REQUEST_MS, PEER_PROOF_ROW_LIMIT};
5935 let Some(factory) = &self.gasp_remote_factory else {
5936 return;
5937 };
5938 let Some(tracker) = self.chain_tracker.as_deref() else {
5939 return;
5940 };
5941 let mut lookup_hosts = None;
5942 let mut topic_peers = HashMap::new();
5943 let mut landed = HashSet::new();
5944 for candidate in candidates.iter().take(PEER_PROOF_ROW_LIMIT) {
5945 if !self.managers.contains_key(&candidate.topic) {
5946 continue;
5947 }
5948 if let std::collections::hash_map::Entry::Vacant(entry) =
5949 topic_peers.entry(candidate.topic.clone())
5950 {
5951 let peers = match self.config.sync_configuration.get(&candidate.topic) {
5952 Some(SyncTarget::Ship) => {
5953 self.discover_ship_peers(&candidate.topic, &mut lookup_hosts)
5954 .await
5955 }
5956 Some(SyncTarget::Peers(peers)) => self.ship_peers_excluding_self(peers.clone()),
5957 _ => Vec::new(),
5958 };
5959 entry.insert(peers);
5960 }
5961 for peer in &topic_peers[&candidate.topic] {
5962 let remote = factory.create_remote(peer, &candidate.topic);
5963 let graph_id = format!("{}.{}", candidate.txid, candidate.output_index);
5964 let mut summary = progress.get();
5965 summary.requests += 1;
5966 progress.set(summary);
5967 let ask =
5968 remote.request_node(&graph_id, &candidate.txid, candidate.output_index, false);
5969 let response = if let Some((sleep, _)) = &self.peer_sync_budget {
5970 crate::gasp::race_or_deadline(ask, sleep(PEER_PROOF_REQUEST_MS)).await
5971 } else {
5972 Some(ask.await)
5973 };
5974 let Some(Ok(node)) = response else {
5975 let mut summary = progress.get();
5976 summary.faults += 1;
5977 progress.set(summary);
5978 continue;
5979 };
5980 if landed.contains(&candidate.txid) {
5981 continue;
5982 }
5983 let Some(proof_hex) = node.proof else {
5984 continue;
5985 };
5986 let Ok(mut proof) = beef_limits::merkle_path_from_hex(
5987 &proof_hex,
5988 beef_limits::COURIER_PROOF_MAX_BYTES,
5989 ) else {
5990 let mut summary = progress.get();
5991 summary.refused += 1;
5992 progress.set(summary);
5993 continue;
5994 };
5995 let valid = match proof.compute_root(Some(&candidate.txid)) {
5996 Ok(root) => tracker
5997 .is_valid_root_for_height(&root, proof.block_height)
5998 .await
5999 .unwrap_or(false),
6000 Err(_) => false,
6001 };
6002 if !valid {
6003 let mut summary = progress.get();
6004 summary.refused += 1;
6005 progress.set(summary);
6006 continue;
6007 }
6008 for leaf in &mut proof.path[0] {
6011 if leaf.hash.as_deref() == Some(&candidate.txid) {
6012 leaf.txid = true;
6013 }
6014 }
6015 match self.land_peer_proof(candidate, &proof).await {
6016 Ok(()) => {
6017 landed.insert(candidate.txid.clone());
6018 let mut summary = progress.get();
6019 summary.landed += 1;
6020 progress.set(summary);
6021 }
6022 Err(error) => {
6023 warn!(%error, "[proof window] verified proof could not be stored");
6024 let mut summary = progress.get();
6025 summary.faults += 1;
6026 progress.set(summary);
6027 }
6028 }
6029 }
6030 }
6031 }
6032
6033 async fn land_peer_proof(
6034 &self,
6035 candidate: &crate::proof_window::HeldOutput,
6036 proof: &bsv_rs::transaction::MerklePath,
6037 ) -> Result<(), EngineError> {
6038 let output = self
6039 .storage
6040 .find_output(
6041 &candidate.txid,
6042 candidate.output_index,
6043 Some(&candidate.topic),
6044 None,
6045 true,
6046 )
6047 .await
6048 .map_err(|e| EngineError::StorageError(e.to_string()))?
6049 .ok_or(EngineError::NodeNotFound)?;
6050 let beef = output
6051 .beef
6052 .ok_or_else(|| EngineError::BeefParseError("missing stored BEEF".into()))?;
6053 let stitched = Self::stitch_proof_into_stored_beef(&beef, &candidate.txid, proof)
6054 .ok_or_else(|| {
6055 EngineError::BeefParseError("proof does not stitch into stored subject".into())
6056 })?;
6057 self.storage
6058 .update_transaction_beef(&candidate.txid, &stitched)
6059 .await
6060 .map_err(|e| EngineError::StorageError(e.to_string()))?;
6061 self.storage
6062 .mark_transaction_proven_at(&candidate.txid, Some(u64::from(proof.block_height)))
6063 .await
6064 .map_err(|e| EngineError::StorageError(e.to_string()))
6065 }
6066
6067 async fn peer_advertisement_versions(&self, topic: &str) -> HashMap<String, String> {
6070 let mut versions: HashMap<String, Vec<String>> = HashMap::new();
6071 for (ad_topic, protocol, scope) in [
6072 ("tm_slap", &b"SLAP"[..], None),
6073 ("tm_ship", &b"SHIP"[..], Some(topic)),
6074 ] {
6075 let rows = self
6076 .storage
6077 .find_utxos_for_topic(ad_topic, None, None, false)
6078 .await
6079 .unwrap_or_default();
6080 for row in rows {
6081 if let Some(domain) =
6082 parse_advertisement_domain_from_script(&row.output_script, protocol, scope)
6083 {
6084 versions
6085 .entry(crate::gasp::peer_origin(&domain))
6086 .or_default()
6087 .push(format!("{}:{}", row.txid, row.output_index));
6088 }
6089 }
6090 }
6091 versions
6092 .into_iter()
6093 .map(|(peer, mut keys)| {
6094 keys.sort();
6095 (peer, keys.join(","))
6096 })
6097 .collect()
6098 }
6099
6100 async fn discover_ship_peers(
6101 &self,
6102 topic: &str,
6103 lookup_hosts: &mut Option<Vec<String>>,
6104 ) -> Vec<String> {
6105 let mut domains = self.local_ship_peers(topic).await;
6106 if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
6107 domains.extend(bootstrap.iter().cloned());
6108 }
6109 if let Some(client) = &self.ship_lookup_client {
6110 if lookup_hosts.is_none() {
6111 *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
6112 }
6113 let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
6114 for host in lookup_hosts.as_ref().into_iter().flatten() {
6115 crate::meter::peer_request();
6116 match client.lookup(host, &question).await {
6117 Ok(outputs) => {
6118 domains.extend(outputs.iter().filter_map(|output| {
6119 parse_lookup_advertisement(output, b"SHIP", topic)
6120 }));
6121 }
6122 Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
6123 }
6124 }
6125 }
6126 self.ship_peers_excluding_self(domains)
6127 }
6128
6129 async fn discover_ship_lookup_hosts(
6130 &self,
6131 client: &dyn crate::ship_lookup::ShipLookupClient,
6132 ) -> Vec<String> {
6133 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
6134 let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
6135 let tracker_origins: HashSet<_> = trackers
6136 .iter()
6137 .map(|host| crate::gasp::peer_origin(host))
6138 .collect();
6139 let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
6140 let mut advertised = Vec::new();
6141 for tracker in &trackers {
6142 crate::meter::peer_request();
6143 match client.lookup(tracker, &question).await {
6144 Ok(outputs) => {
6145 advertised.extend(outputs.iter().filter_map(|output| {
6146 parse_lookup_advertisement(output, b"SLAP", "ls_ship")
6147 }));
6148 }
6149 Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
6150 }
6151 }
6152 let providers = self
6153 .ship_peers_excluding_self(advertised)
6154 .into_iter()
6155 .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
6156 .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
6157 trackers.into_iter().chain(providers).collect()
6158 }
6159
6160 fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
6161 crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
6162 self.config.hosting_url.as_ref().is_none_or(|our_url| {
6163 crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
6164 })
6165 }))
6166 }
6167
6168 async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
6170 let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
6171 warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
6172 return Vec::new();
6173 };
6174
6175 let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
6176
6177 let refs = match ship_ls.lookup(&question).await {
6178 Ok(LookupResult::OutputList(refs)) => refs,
6179 Ok(LookupResult::Answer(_)) => {
6180 error!(
6181 "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
6182 LookupAnswer; expected OutputList. Skipping."
6183 );
6184 return Vec::new();
6185 }
6186 Err(e) => {
6187 error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
6188 return Vec::new();
6189 }
6190 };
6191
6192 let mut domains = HashSet::new();
6193 for reference in &refs {
6194 if let Ok(Some(output)) = self
6195 .storage
6196 .find_output(&reference.txid, reference.output_index, None, None, false)
6197 .await
6198 {
6199 if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
6200 if let Some(ref our_url) = self.config.hosting_url {
6204 if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
6205 || crate::gasp::peer_origin(&domain)
6206 == crate::gasp::peer_origin(our_url)
6207 {
6208 continue;
6209 }
6210 }
6211 domains.insert(domain);
6212 }
6213 }
6214 }
6215
6216 crate::gasp::ship_peers_by_origin(domains)
6220 }
6221
6222 pub fn storage(&self) -> &dyn Storage {
6228 self.storage.as_ref()
6229 }
6230
6231 pub fn config(&self) -> &EngineConfig {
6233 &self.config
6234 }
6235}
6236
6237pub(crate) async fn verify_spv_like_the_reference(
6254 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6255 verify_scripts: bool,
6256 beef_bytes: &[u8],
6257 subject_txid: &str,
6258 budget: DoorBudget,
6259) -> Result<Option<WalkCouldNotRun>, EngineError> {
6260 verify_spv_trusting(
6261 chain_tracker,
6262 verify_scripts,
6263 beef_bytes,
6264 subject_txid,
6265 &HashSet::new(),
6266 budget,
6267 )
6268 .await
6269}
6270
6271async fn verify_spv_trusting(
6276 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6277 verify_scripts: bool,
6278 beef_bytes: &[u8],
6279 subject_txid: &str,
6280 trusted: &HashSet<String>,
6281 budget: DoorBudget,
6282) -> Result<Option<WalkCouldNotRun>, EngineError> {
6283 if verify_scripts {
6284 Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
6285 } else {
6286 Engine::verify_spv_structurally(chain_tracker, beef_bytes)
6287 .await
6288 .map(|()| None)
6289 }
6290}
6291
6292fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
6296 let mut proven: HashMap<&str, usize> = HashMap::new();
6297 for (at, bump) in beef.bumps.iter().enumerate() {
6298 for leaf in bump.path.first().into_iter().flatten() {
6299 if let Some(hash) = leaf.hash.as_deref() {
6300 proven.entry(hash).or_insert(at);
6301 }
6302 }
6303 }
6304 proven
6305}
6306
6307fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
6312 let bodies: HashMap<String, &Transaction> = beef
6313 .txs
6314 .iter()
6315 .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
6316 .collect();
6317 let proven = proven_index(beef);
6318 let mut cover = HashSet::new();
6319 let mut queue = vec![from.to_string()];
6320 while let Some(txid) = queue.pop() {
6321 if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
6322 continue;
6323 }
6324 if let Some(tx) = bodies.get(&txid) {
6325 queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
6326 }
6327 }
6328 cover
6329}
6330
6331struct SubmitAsFinalized<'e> {
6334 engine: &'e Engine,
6335 sink: crate::gasp_overlay::FinalizedGraphSink,
6336 peer_url: &'e str,
6337 topic: &'e str,
6338 submitted: &'e std::cell::Cell<u64>,
6339}
6340
6341#[async_trait::async_trait(?Send)]
6342impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
6343 async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
6344 let landed = self
6348 .engine
6349 .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
6350 .await;
6351 if landed {
6352 Ok(())
6353 } else {
6354 Err(crate::gasp::GASPError::StorageError(
6357 "a finalize submit did not land".to_string(),
6358 ))
6359 }
6360 }
6361}
6362
6363#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6367pub struct GASPSyncResult {
6368 #[serde(default, flatten)]
6369 pub cost: crate::meter::SyncCost,
6370 #[serde(default)]
6371 pub filter: crate::sync_load::SyncFilter,
6372 #[serde(default)]
6373 pub topics_filtered: Vec<String>,
6374 pub topics_synced: HashMap<String, TopicSyncResult>,
6376}
6377
6378#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6380pub struct TopicSyncResult {
6381 #[serde(default)]
6382 pub peer_results: HashMap<String, crate::backfill::PeerSyncResult>,
6383 #[serde(default)]
6384 pub dead_graphs: u64,
6385 #[serde(default)]
6386 pub expired_swept: u64,
6387 #[serde(default, flatten)]
6388 pub cost: crate::meter::SyncCost,
6389 #[serde(default)]
6390 pub root_bounds: Vec<crate::sync_load::RootBound>,
6391 #[serde(default)]
6392 pub peers_skipped: Vec<crate::sync_load::SkippedPeer>,
6393 #[serde(default)]
6394 pub peers_filtered: Vec<String>,
6395 #[serde(default)]
6397 pub roots_proven: u64,
6398 #[serde(default)]
6399 pub roots_unproven: u64,
6400 #[serde(default)]
6402 pub cursor_held_by: Vec<String>,
6403 #[serde(default)]
6405 pub expired_refused: u64,
6406 pub peers: Vec<String>,
6408 pub sync_type: String,
6410 pub errors: Vec<String>,
6416 #[serde(default)]
6422 pub pruned_inputs: u64,
6423 #[serde(default)]
6428 pub discarded_graphs: u64,
6429 #[serde(default)]
6435 pub anchor_walk_held_graphs: u64,
6436 #[serde(default)]
6446 pub finalized_graphs: u64,
6447 #[serde(default)]
6455 pub deadline_dropped_graphs: u64,
6456 #[serde(default)]
6461 pub cursor_moves: Vec<CursorMove>,
6462 #[serde(default)]
6466 pub deferred_graphs: u64,
6467 #[serde(default)]
6469 pub resumed_graphs: u64,
6470 #[serde(default)]
6473 pub converged_graphs: u64,
6474 #[serde(default)]
6476 pub dropped_graphs: Vec<DroppedDeferral>,
6477 #[serde(default)]
6482 pub stalled_graphs: u64,
6483 #[serde(default)]
6487 pub held_back_graphs: u64,
6488 #[serde(default)]
6493 pub backed_off_graphs: u64,
6494 #[serde(default)]
6497 pub waiting_graphs: u64,
6498 #[serde(default)]
6503 pub idle_counted: u64,
6504 #[serde(default)]
6510 pub unanchorable_graphs: u64,
6511 #[serde(default)]
6514 pub unanchorable_cleared: u64,
6515 #[serde(default)]
6520 pub strikes_refused: u64,
6521 #[serde(default)]
6527 pub faulted_roots: Vec<crate::gasp::FaultedRoot>,
6528}
6529
6530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6533pub struct DroppedDeferral {
6534 pub peer: String,
6536 pub outpoint: String,
6538 pub reason: String,
6540}
6541
6542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6545pub struct CursorMove {
6546 pub peer: String,
6548 pub from: u64,
6550 pub to: u64,
6552}
6553
6554pub(crate) fn current_timestamp_ms() -> f64 {
6559 #[cfg(target_arch = "wasm32")]
6560 {
6561 js_sys::Date::now()
6562 }
6563 #[cfg(not(target_arch = "wasm32"))]
6564 {
6565 std::time::SystemTime::now()
6566 .duration_since(std::time::UNIX_EPOCH)
6567 .map_or(0.0, |d| d.as_millis() as f64)
6568 }
6569}
6570
6571fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
6577 parse_advertisement_domain_from_script(output_script, b"SHIP", None)
6578}
6579
6580fn parse_lookup_advertisement(
6581 output: &OutputListItem,
6582 protocol: &[u8],
6583 scope: &str,
6584) -> Option<String> {
6585 let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
6586 .ok()?;
6587 let script = tx
6588 .outputs
6589 .get(output.output_index as usize)?
6590 .locking_script
6591 .to_binary();
6592 parse_advertisement_domain_from_script(&script, protocol, Some(scope))
6593}
6594
6595fn parse_advertisement_domain_from_script(
6596 output_script: &[u8],
6597 protocol: &[u8],
6598 scope: Option<&str>,
6599) -> Option<String> {
6600 use bsv_rs::script::templates::PushDrop;
6601
6602 let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
6603 let pushdrop = PushDrop::decode(&script.into()).ok()?;
6604
6605 if pushdrop.fields.len() < 3 {
6606 return None;
6607 }
6608
6609 if pushdrop.fields[0] != protocol {
6610 return None;
6611 }
6612
6613 if scope.is_some_and(|expected| {
6614 pushdrop
6615 .fields
6616 .get(3)
6617 .is_none_or(|actual| actual != expected.as_bytes())
6618 }) {
6619 return None;
6620 }
6621
6622 let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
6623 if domain.is_empty() {
6624 return None;
6625 }
6626
6627 Some(domain)
6628}
6629
6630#[derive(Debug, thiserror::Error)]
6639pub enum EngineError {
6640 #[error("This server does not support this topic: {0}")]
6641 UnsupportedTopic(String),
6642
6643 #[error("Lookup service not found for provider: {0}")]
6644 LookupServiceNotFound(String),
6645
6646 #[error("No matching output found!")]
6649 NodeNotFound,
6650
6651 #[error("lookup failed: {0}")]
6652 LookupFailed(String),
6653
6654 #[error("invalid query: {0}")]
6661 InvalidQuery(String),
6662
6663 #[error("storage error: {0}")]
6664 StorageError(String),
6665
6666 #[error("broadcast error: {0}")]
6667 BroadcastError(String),
6668
6669 #[error("SPV verification failed: {0}")]
6670 SpvError(String),
6671
6672 #[error("BEEF parsing failed: {0}")]
6673 BeefParseError(String),
6674
6675 #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6685 ScriptVerificationFailed {
6686 subject_txid: String,
6687 input_index: u32,
6688 reason: String,
6689 },
6690
6691 #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6698 ScriptWalkInconclusive {
6699 at_txid: String,
6700 subject_judged: bool,
6701 reason: String,
6702 },
6703
6704 #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6709 ScriptWalkOverBudget {
6710 at_txid: String,
6711 subject_judged: bool,
6712 limb: DoorLimb,
6713 what: String,
6714 },
6715
6716 #[error(
6726 "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6727 .0.limb.as_str(),
6728 .0.what
6729 )]
6730 WalkCouldNotRun(WalkCouldNotRun),
6731
6732 #[error("{0}")]
6733 Other(String),
6734}
6735
6736impl From<StorageError> for EngineError {
6737 fn from(e: StorageError) -> Self {
6738 EngineError::StorageError(e.to_string())
6739 }
6740}
6741
6742impl From<TopicManagerError> for EngineError {
6743 fn from(e: TopicManagerError) -> Self {
6744 EngineError::Other(e.to_string())
6745 }
6746}
6747
6748impl From<LookupServiceError> for EngineError {
6749 fn from(e: LookupServiceError) -> Self {
6750 EngineError::LookupFailed(e.to_string())
6751 }
6752}
6753
6754impl From<AdvertiserError> for EngineError {
6755 fn from(e: AdvertiserError) -> Self {
6756 EngineError::Other(e.to_string())
6757 }
6758}
6759
6760pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6770 let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6771 order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6772 order
6773}
6774
6775#[cfg(test)]
6776mod tests {
6777 use super::*;
6778 use crate::lookup_service::LookupService as LookupServiceTrait;
6779 use crate::storage::memory::MemoryStorage;
6780 use crate::topic_manager::TopicManager as TopicManagerTrait;
6781 use async_trait::async_trait;
6782 use std::sync::Mutex;
6783
6784 #[test]
6788 fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6789 let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6790 let config: SyncConfiguration = HashMap::from([
6791 ("tm_a".to_string(), SyncTarget::Ship),
6792 ("tm_z".to_string(), peers("https://z")),
6793 ("tm_b".to_string(), SyncTarget::Ship),
6794 ("tm_m".to_string(), peers("https://m")),
6795 ("tm_off".to_string(), SyncTarget::Disabled),
6796 ]);
6797 let order: Vec<&str> = sync_order(&config)
6798 .iter()
6799 .map(|(t, _)| t.as_str())
6800 .collect();
6801 assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6802 }
6803
6804 struct MockTopicManager {
6807 admit_indices: Vec<u32>,
6808 }
6809
6810 impl MockTopicManager {
6811 fn admitting(indices: Vec<u32>) -> Self {
6812 Self {
6813 admit_indices: indices,
6814 }
6815 }
6816 }
6817
6818 #[async_trait(?Send)]
6819 impl TopicManagerTrait for MockTopicManager {
6820 async fn identify_admissible_outputs(
6821 &self,
6822 _tx: &Transaction,
6823 _previous_coins: &[u8],
6824 _off_chain_values: Option<&[u8]>,
6825 _mode: SubmitMode,
6826 _context: &TopicAdmittanceContext,
6827 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6828 Ok(AdmittanceInstructions {
6829 outputs_to_admit: self.admit_indices.clone(),
6830 coins_to_retain: vec![],
6831 coins_removed: None,
6832 })
6833 }
6834
6835 async fn get_documentation(&self) -> String {
6836 "Mock topic manager".to_string()
6837 }
6838
6839 async fn get_metadata(&self) -> ServiceMetadata {
6840 ServiceMetadata {
6841 name: "mock-tm".to_string(),
6842 description: Some("Mock for testing".to_string()),
6843 ..Default::default()
6844 }
6845 }
6846 }
6847
6848 struct MockLookupService {
6851 records: Mutex<Vec<UTXOReference>>,
6852 }
6853
6854 impl MockLookupService {
6855 fn new() -> Self {
6856 Self {
6857 records: Mutex::new(Vec::new()),
6858 }
6859 }
6860 }
6861
6862 #[async_trait(?Send)]
6863 impl LookupServiceTrait for MockLookupService {
6864 fn admission_mode(&self) -> AdmissionMode {
6865 AdmissionMode::LockingScript
6866 }
6867
6868 fn spend_notification_mode(&self) -> SpendNotificationMode {
6869 SpendNotificationMode::None
6870 }
6871
6872 async fn output_admitted_by_topic(
6873 &self,
6874 payload: &OutputAdmittedByTopic,
6875 ) -> Result<(), LookupServiceError> {
6876 let (txid, oi) = match payload {
6877 OutputAdmittedByTopic::LockingScript {
6878 txid, output_index, ..
6879 } => (txid.clone(), *output_index),
6880 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
6881 ("whole".into(), *output_index)
6882 }
6883 };
6884 self.records.lock().unwrap().push(UTXOReference {
6885 txid,
6886 output_index: oi,
6887 });
6888 Ok(())
6889 }
6890
6891 async fn output_evicted(
6892 &self,
6893 txid: &str,
6894 output_index: u32,
6895 ) -> Result<(), LookupServiceError> {
6896 self.records
6897 .lock()
6898 .unwrap()
6899 .retain(|r| !(r.txid == txid && r.output_index == output_index));
6900 Ok(())
6901 }
6902
6903 async fn lookup(
6904 &self,
6905 _question: &LookupQuestion,
6906 ) -> Result<LookupResult, LookupServiceError> {
6907 Ok(LookupResult::OutputList(
6908 self.records.lock().unwrap().clone(),
6909 ))
6910 }
6911
6912 async fn get_documentation(&self) -> String {
6913 "Mock lookup service".to_string()
6914 }
6915
6916 async fn get_metadata(&self) -> ServiceMetadata {
6917 ServiceMetadata {
6918 name: "mock-ls".to_string(),
6919 ..Default::default()
6920 }
6921 }
6922 }
6923
6924 const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
6928 const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
6929
6930 fn decode_hex(hex: &str) -> Vec<u8> {
6931 (0..hex.len())
6932 .step_by(2)
6933 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
6934 .collect()
6935 }
6936
6937 fn test_beef() -> Vec<u8> {
6938 decode_hex(TEST_BEEF_HEX)
6939 }
6940
6941 fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
6942 TaggedBEEF::new(
6943 test_beef(),
6944 topics.into_iter().map(str::to_string).collect(),
6945 )
6946 }
6947
6948 fn make_engine(admit_indices: Vec<u32>) -> Engine {
6951 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6952 managers.insert(
6953 "tm_test".to_string(),
6954 Box::new(MockTopicManager::admitting(admit_indices)),
6955 );
6956
6957 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
6958 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
6959
6960 let storage = Box::new(MemoryStorage::new());
6961
6962 Engine::new(
6963 managers,
6964 lookup_services,
6965 storage,
6966 None,
6967 EngineConfig::default(),
6968 )
6969 }
6970
6971 #[tokio::test]
6980 async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
6981 use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
6982 use std::cell::RefCell;
6983 use std::rc::Rc;
6984
6985 let parsed = Beef::from_binary(&test_beef()).unwrap();
6988 let parent = parsed.txs[0].tx().unwrap().clone();
6989 let child = parsed.txs[1].tx().unwrap().clone();
6990 let child_id = child.id();
6991 let parent_id = parent.id();
6992 let mut beef = Beef::new();
6993 for bump in &parsed.bumps {
6999 beef.merge_bump(bump.clone());
7000 }
7001 beef.merge_transaction(child);
7002 beef.merge_transaction(parent);
7003 let mut w = bsv_rs::primitives::encoding::Writer::new();
7004 w.write_u32_le(ATOMIC_BEEF);
7005 let mut le = hex::decode(&child_id).unwrap();
7006 le.reverse();
7007 w.write_bytes(&le);
7008 beef.to_writer(&mut w);
7009 let atomic_wire_order = w.into_bytes();
7010
7011 struct RecordingTm(Rc<RefCell<Option<String>>>);
7013 #[async_trait(?Send)]
7014 impl TopicManagerTrait for RecordingTm {
7015 async fn identify_admissible_outputs(
7016 &self,
7017 tx: &Transaction,
7018 _previous_coins: &[u8],
7019 _off_chain_values: Option<&[u8]>,
7020 _mode: SubmitMode,
7021 _context: &TopicAdmittanceContext,
7022 ) -> Result<AdmittanceInstructions, TopicManagerError> {
7023 *self.0.borrow_mut() = Some(tx.id());
7024 Ok(AdmittanceInstructions {
7025 outputs_to_admit: vec![0],
7026 coins_to_retain: vec![],
7027 coins_removed: None,
7028 })
7029 }
7030 async fn get_documentation(&self) -> String {
7031 String::new()
7032 }
7033 async fn get_metadata(&self) -> ServiceMetadata {
7034 ServiceMetadata {
7035 name: "tm_test".to_string(),
7036 ..Default::default()
7037 }
7038 }
7039 }
7040
7041 let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
7042 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7043 managers.insert(
7044 "tm_test".to_string(),
7045 Box::new(RecordingTm(Rc::clone(&seen))),
7046 );
7047 let engine = Engine::new(
7048 managers,
7049 HashMap::new(),
7050 Box::new(MemoryStorage::new()),
7051 None,
7052 EngineConfig::default(),
7053 );
7054
7055 let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
7056 let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
7057
7058 let handed = seen.borrow().clone().expect("TM was not invoked");
7059 assert_eq!(
7060 handed, child_id,
7061 "TM must be handed the atomic subject, got the wire-order pick"
7062 );
7063 assert_ne!(handed, parent_id);
7064 }
7065
7066 #[tokio::test]
7079 async fn history_hydration_preserves_the_stored_proof() {
7080 use bsv_rs::transaction::Beef;
7081
7082 let engine = make_engine(vec![0]);
7083 let stored = test_beef();
7084 let bumps_before = Beef::from_binary(&stored)
7085 .expect("fixture BEEF parses")
7086 .bumps
7087 .len();
7088 assert!(
7089 bumps_before > 0,
7090 "fixture must actually carry a proof, or this cell proves nothing"
7091 );
7092
7093 let output = Output {
7094 txid: TEST_TXID.to_string(),
7095 output_index: 0,
7096 output_script: vec![0x76, 0xa9],
7097 satoshis: 1000,
7098 topic: "tm_test".to_string(),
7099 spent: false,
7100 outputs_consumed: vec![],
7103 consumed_by: vec![],
7104 beef: Some(stored.clone()),
7105 block_height: None,
7106 score: Some(1000.0),
7107 };
7108
7109 let hydrated = engine
7110 .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
7111 .await
7112 .expect("hydration must not error")
7113 .expect("hydration must return the output");
7114 let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
7115 .expect("hydrated BEEF parses");
7116
7117 assert_eq!(
7118 beef.bumps.len(),
7119 bumps_before,
7120 "asking for history must not strip the merkle proof (issue #4)"
7121 );
7122 }
7123
7124 #[tokio::test]
7125 async fn test_submit_unsupported_topic_errors() {
7126 let engine = make_engine(vec![0]);
7127 let beef = test_tagged_beef(vec!["tm_nonexistent"]);
7128
7129 let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
7130 assert!(result.is_err());
7131 assert!(matches!(
7132 result.unwrap_err(),
7133 EngineError::UnsupportedTopic(_)
7134 ));
7135 }
7136
7137 #[tokio::test]
7138 async fn test_submit_admits_outputs() {
7139 let engine = make_engine(vec![0, 1]);
7140 let beef = test_tagged_beef(vec!["tm_test"]);
7141
7142 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7143
7144 let instructions = steak.get("tm_test").unwrap();
7146 assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
7147
7148 let txid = TEST_TXID;
7150 let found = engine
7151 .storage()
7152 .find_output(txid, 0, Some("tm_test"), None, true)
7153 .await
7154 .unwrap()
7155 .expect("Output 0 should be in storage");
7156
7157 assert_eq!(found.txid, TEST_TXID);
7158 assert_eq!(found.satoshis, 26172); assert!(
7160 !found.output_script.is_empty(),
7161 "Script should be populated"
7162 );
7163 assert!(found.beef.is_some(), "BEEF should be stored");
7164 assert!(!found.spent);
7165 }
7166
7167 #[tokio::test]
7168 async fn test_submit_duplicate_is_skipped() {
7169 let engine = make_engine(vec![0]);
7170 let beef = test_tagged_beef(vec!["tm_test"]);
7171
7172 let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7174 assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
7175
7176 let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7178 assert!(steak2["tm_test"].outputs_to_admit.is_empty());
7179 }
7180
7181 #[tokio::test]
7182 async fn test_submit_notifies_lookup_service() {
7183 let engine = make_engine(vec![0]);
7184 let beef = test_tagged_beef(vec!["tm_test"]);
7185
7186 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7187
7188 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7190 let answer = engine.lookup(&question, None).await.unwrap();
7191
7192 match answer {
7193 LookupAnswer::OutputList { outputs } => {
7194 assert_eq!(outputs.len(), 1);
7195 }
7196 _ => panic!("Expected OutputList"),
7197 }
7198 }
7199
7200 #[tokio::test]
7204 async fn test_lookup_with_txids_aligns_txids_with_outputs() {
7205 let engine = make_engine(vec![0]);
7206 let beef = test_tagged_beef(vec!["tm_test"]);
7207 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7208
7209 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7210 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7211 match answer {
7212 LookupAnswer::OutputList { outputs } => {
7213 assert_eq!(outputs.len(), 1);
7214 assert_eq!(txids.len(), outputs.len(), "one txid per output");
7215 assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
7216 }
7217 _ => panic!("Expected OutputList"),
7218 }
7219 }
7220
7221 #[tokio::test]
7222 async fn proof_window_holds_an_aged_child_with_a_proven_parent() {
7223 let mut engine = make_engine(vec![0]);
7224 let storage = std::rc::Rc::new(MemoryStorage::new());
7225 engine.storage = Box::new(storage.clone());
7226 engine
7227 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7228 .await
7229 .unwrap();
7230 assert!(beef_limits::own_proof(&test_beef(), TEST_TXID)
7231 .unwrap()
7232 .is_none());
7233 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7234 storage.advance_clock((crate::proof_window::PROOFLESS_FLAG_MS / 1000) as u64);
7235 assert_eq!(
7236 engine
7237 .lookup_with_txids(&question, None)
7238 .await
7239 .unwrap()
7240 .1
7241 .len(),
7242 1
7243 );
7244 storage.advance_clock(1);
7245 assert!(
7246 engine
7247 .lookup_with_txids(&question, None)
7248 .await
7249 .unwrap()
7250 .1
7251 .is_empty(),
7252 "an aged unproven child must be held even when its parent is proven"
7253 );
7254 }
7255
7256 #[tokio::test]
7257 async fn proof_window_peer_proof_verifies_then_restores_lookup() {
7258 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7259 let mut engine = make_engine(vec![0]);
7260 let storage = std::rc::Rc::new(MemoryStorage::new());
7261 engine.storage = Box::new(storage.clone());
7262 engine
7263 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7264 .await
7265 .unwrap();
7266 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7267 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7268 let candidate = HeldOutput {
7269 txid: TEST_TXID.into(),
7270 output_index: 0,
7271 topic: "tm_test".into(),
7272 };
7273 let (_, mut remote) = budget_page_engine(1);
7274 remote.node_cost_ms = 0;
7275 remote.nodes = std::rc::Rc::new(HashMap::from([(
7276 TEST_TXID.into(),
7277 crate::types::GASPNode {
7278 graph_id: String::new(),
7279 raw_tx: String::new(),
7280 output_index: 0,
7281 proof: Some(single_leaf_bump_hex(TEST_TXID, 800_000)),
7282 tx_metadata: None,
7283 output_metadata: None,
7284 inputs: None,
7285 },
7286 )]));
7287 engine
7288 .config
7289 .sync_configuration
7290 .insert("tm_test".into(), SyncTarget::Ship);
7291 engine.ship_bootstrap_peers.insert(
7292 "tm_test".into(),
7293 vec!["https://a.example".into(), "https://b.example".into()],
7294 );
7295 engine.set_gasp_remote_factory(Box::new(remote));
7296 let progress = std::cell::Cell::new(PeerProofSummary::default());
7297 engine.chain_tracker = Some(Box::new(bsv_rs::transaction::MockChainTracker::new(
7298 800_001,
7299 )));
7300 engine
7301 .rerequest_held_proofs(std::slice::from_ref(&candidate), &progress)
7302 .await;
7303 assert_eq!(progress.get().requests, 2, "ask every topic peer");
7304 assert_eq!(progress.get().landed, 0);
7305 assert_eq!(progress.get().refused, 2);
7306 assert!(engine
7307 .lookup_with_txids(&question, None)
7308 .await
7309 .unwrap()
7310 .1
7311 .is_empty());
7312 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7313 tracker.add_root(800_000, TEST_TXID.into());
7314 engine.chain_tracker = Some(Box::new(tracker));
7315 engine.rerequest_held_proofs(&[candidate], &progress).await;
7316 assert_eq!(progress.get().requests, 4);
7317 assert_eq!(
7318 progress.get().landed,
7319 1,
7320 "count durable proofs, not peer answers"
7321 );
7322 let (_, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7323 assert_eq!(txids, vec![TEST_TXID]);
7324 let stored = storage
7325 .find_output(TEST_TXID, 0, None, None, true)
7326 .await
7327 .unwrap()
7328 .unwrap();
7329 assert!(
7330 beef_limits::own_proof(stored.beef.as_ref().unwrap(), TEST_TXID)
7331 .unwrap()
7332 .is_some()
7333 );
7334 assert_eq!(storage.proven_at_height(TEST_TXID), Some(Some(800_000)));
7335 }
7336
7337 #[tokio::test]
7338 async fn proof_window_peer_hash_leaf_is_stored_as_own_proof() {
7339 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7340 let mut engine = make_engine(vec![0]);
7341 let storage = std::rc::Rc::new(MemoryStorage::new());
7342 engine.storage = Box::new(storage.clone());
7343 engine
7344 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7345 .await
7346 .unwrap();
7347 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7348 let mut proof =
7349 bsv_rs::transaction::MerklePath::from_hex(&single_leaf_bump_hex(TEST_TXID, 800_000))
7350 .unwrap();
7351 proof.path[0][0].txid = false;
7352 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7353 tracker.add_root(800_000, proof.compute_root(Some(TEST_TXID)).unwrap());
7354 engine.chain_tracker = Some(Box::new(tracker));
7355 let (_, mut remote) = budget_page_engine(1);
7356 remote.node_cost_ms = 0;
7357 remote.nodes = std::rc::Rc::new(HashMap::from([(
7358 TEST_TXID.into(),
7359 crate::types::GASPNode {
7360 graph_id: String::new(),
7361 raw_tx: String::new(),
7362 output_index: 0,
7363 proof: Some(proof.to_hex()),
7364 tx_metadata: None,
7365 output_metadata: None,
7366 inputs: None,
7367 },
7368 )]));
7369 engine.config.sync_configuration.insert(
7370 "tm_test".into(),
7371 SyncTarget::Peers(vec!["https://peer.example".into()]),
7372 );
7373 engine.set_gasp_remote_factory(Box::new(remote));
7374 let progress = std::cell::Cell::new(PeerProofSummary::default());
7375 engine
7376 .rerequest_held_proofs(
7377 &[HeldOutput {
7378 txid: TEST_TXID.into(),
7379 output_index: 0,
7380 topic: "tm_test".into(),
7381 }],
7382 &progress,
7383 )
7384 .await;
7385 assert_eq!(progress.get().landed, 1);
7386 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7387 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7388 assert_eq!(txids, vec![TEST_TXID]);
7389 let LookupAnswer::OutputList { outputs } = answer else {
7390 panic!("expected outputs")
7391 };
7392 assert!(
7393 beef_limits::own_proof(&outputs[0].beef, TEST_TXID)
7394 .unwrap()
7395 .is_some(),
7396 "a proven flag must have the child's own path in the served BEEF"
7397 );
7398 }
7399
7400 #[tokio::test]
7401 async fn proof_window_silent_peers_yield_at_the_request_budget() {
7402 use crate::proof_window::{HeldOutput, PeerProofSummary, PEER_PROOF_REQUEST_MS};
7403 let (mut engine, remote) = budget_page_engine(1);
7404 engine.config.sync_configuration.insert(
7405 "tm_test".into(),
7406 SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
7407 );
7408 let elapsed = remote.elapsed.clone();
7409 engine.set_peer_sync_budget(
7410 std::rc::Rc::new(move |ms| {
7411 assert_eq!(ms, PEER_PROOF_REQUEST_MS);
7412 let deadline = elapsed.get() + ms;
7413 let elapsed = elapsed.clone();
7414 Box::pin(std::future::poll_fn(move |_| {
7415 if elapsed.get() >= deadline {
7416 std::task::Poll::Ready(())
7417 } else {
7418 std::task::Poll::Pending
7419 }
7420 }))
7421 }),
7422 30_000,
7423 );
7424 let candidate = HeldOutput {
7425 txid: remote.outputs[0].txid.clone(),
7426 output_index: 0,
7427 topic: "tm_test".into(),
7428 };
7429 engine.set_gasp_remote_factory(Box::new(remote));
7430 let progress = std::cell::Cell::new(PeerProofSummary::default());
7431 engine.rerequest_held_proofs(&[candidate], &progress).await;
7432 assert_eq!(progress.get().requests, 2);
7433 assert_eq!(progress.get().faults, 2);
7434 assert_eq!(progress.get().landed, 0);
7435 }
7436
7437 #[test]
7439 fn sync_limit_clamp() {
7440 assert_eq!(
7441 Engine::clamp_sync_limit(None),
7442 Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
7443 "absent limit gets the default page, never unbounded"
7444 );
7445 assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
7446 assert_eq!(
7447 Engine::clamp_sync_limit(Some(u64::MAX)),
7448 Engine::SYNC_RESPONSE_MAX_LIMIT,
7449 "a huge caller-supplied limit is capped"
7450 );
7451 }
7452
7453 #[tokio::test]
7454 async fn test_lookup_unknown_service_errors() {
7455 let engine = make_engine(vec![]);
7456 let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
7457
7458 let result = engine.lookup(&question, None).await;
7459 assert!(result.is_err());
7460 assert!(matches!(
7461 result.unwrap_err(),
7462 EngineError::LookupServiceNotFound(_)
7463 ));
7464 }
7465
7466 #[tokio::test]
7467 async fn test_list_topic_managers() {
7468 let engine = make_engine(vec![]);
7469 let managers = engine.list_topic_managers().await;
7470
7471 assert_eq!(managers.len(), 1);
7472 assert!(managers.contains_key("tm_test"));
7473 assert_eq!(managers["tm_test"].name, "mock-tm");
7474 }
7475
7476 #[tokio::test]
7477 async fn test_list_lookup_service_providers() {
7478 let engine = make_engine(vec![]);
7479 let services = engine.list_lookup_service_providers().await;
7480
7481 assert_eq!(services.len(), 1);
7482 assert!(services.contains_key("ls_test"));
7483 assert_eq!(services["ls_test"].name, "mock-ls");
7484 }
7485
7486 #[tokio::test]
7487 async fn test_get_documentation() {
7488 let engine = make_engine(vec![]);
7489
7490 let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
7491 assert_eq!(tm_docs, "Mock topic manager");
7492
7493 let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
7494 assert_eq!(ls_docs, "Mock lookup service");
7495
7496 let missing = engine
7497 .get_documentation_for_topic_manager("tm_missing")
7498 .await;
7499 assert_eq!(missing, "No documentation found!");
7500 }
7501
7502 #[tokio::test]
7503 async fn test_provide_foreign_sync_response() {
7504 let engine = make_engine(vec![0]);
7505
7506 let beef = test_tagged_beef(vec!["tm_test"]);
7508 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7509
7510 let request = GASPInitialRequest {
7512 version: 1,
7513 since: 0,
7514 limit: Some(100),
7515 };
7516 let response = engine
7517 .provide_foreign_sync_response(&request, "tm_test")
7518 .await
7519 .unwrap();
7520
7521 assert_eq!(response.utxo_list.len(), 1);
7523 assert_eq!(response.utxo_list[0].txid, TEST_TXID);
7524 assert_eq!(response.since, 0);
7525 }
7526
7527 #[tokio::test]
7528 async fn test_delete_utxo_deep_simple() {
7529 let engine = make_engine(vec![0]);
7530 let beef = test_tagged_beef(vec!["tm_test"]);
7531
7532 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7533
7534 let txid = TEST_TXID;
7535 let output = engine
7536 .storage()
7537 .find_output(txid, 0, Some("tm_test"), None, false)
7538 .await
7539 .unwrap()
7540 .unwrap();
7541
7542 engine.delete_utxo_deep(&output, None).await.unwrap();
7544
7545 let found = engine
7547 .storage()
7548 .find_output(txid, 0, Some("tm_test"), None, false)
7549 .await
7550 .unwrap();
7551 assert!(found.is_none());
7552 }
7553
7554 #[tokio::test]
7555 async fn test_sync_config_defaults_to_ship() {
7556 let engine = make_engine(vec![]);
7557 let sync = &engine.config.sync_configuration;
7558
7559 assert!(sync.contains_key("tm_test"));
7561 assert!(matches!(sync["tm_test"], SyncTarget::Ship));
7562 }
7563
7564 #[tokio::test]
7565 async fn test_multiple_topics_in_single_submit() {
7566 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7567 managers.insert(
7568 "tm_alpha".to_string(),
7569 Box::new(MockTopicManager::admitting(vec![0])),
7570 );
7571 managers.insert(
7572 "tm_beta".to_string(),
7573 Box::new(MockTopicManager::admitting(vec![1])),
7574 );
7575
7576 let engine = Engine::new(
7577 managers,
7578 HashMap::new(),
7579 Box::new(MemoryStorage::new()),
7580 None,
7581 EngineConfig::default(),
7582 );
7583
7584 let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
7585
7586 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7587
7588 assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
7589 assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
7590 }
7591
7592 #[tokio::test]
7593 async fn test_evict_output_with_topic() {
7594 let engine = make_engine(vec![0]);
7595 let beef = test_tagged_beef(vec!["tm_test"]);
7596 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7597
7598 let found = engine
7600 .storage()
7601 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7602 .await
7603 .unwrap();
7604 assert!(found.is_some(), "Output should exist before eviction");
7605
7606 engine
7608 .evict_output(TEST_TXID, 0, Some("tm_test"))
7609 .await
7610 .unwrap();
7611
7612 let found = engine
7614 .storage()
7615 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7616 .await
7617 .unwrap();
7618 assert!(found.is_none(), "Output should be gone after eviction");
7619
7620 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7622 let answer = engine.lookup(&question, None).await.unwrap();
7623 match answer {
7624 LookupAnswer::OutputList { outputs } => {
7625 assert!(
7626 outputs.is_empty(),
7627 "Lookup should return empty after eviction"
7628 );
7629 }
7630 _ => panic!("Expected OutputList"),
7631 }
7632 }
7633
7634 #[tokio::test]
7635 async fn test_evict_output_without_topic() {
7636 let engine = make_engine(vec![0]);
7637 let beef = test_tagged_beef(vec!["tm_test"]);
7638 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7639
7640 engine.evict_output(TEST_TXID, 0, None).await.unwrap();
7642
7643 let found = engine
7644 .storage()
7645 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7646 .await
7647 .unwrap();
7648 assert!(
7649 found.is_none(),
7650 "Output should be gone after topic-less eviction"
7651 );
7652 }
7653
7654 #[tokio::test]
7655 async fn test_evict_nonexistent_output_is_ok() {
7656 let engine = make_engine(vec![]);
7657
7658 let result = engine
7660 .evict_output("nonexistent_txid", 99, Some("tm_test"))
7661 .await;
7662 assert!(result.is_ok(), "Evicting nonexistent output should succeed");
7663 }
7664
7665 use crate::broadcaster::Broadcaster;
7668 use std::sync::Arc;
7669
7670 type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
7671
7672 struct MockBroadcaster {
7673 calls: BroadcastCallLog,
7674 }
7675
7676 impl MockBroadcaster {
7677 fn new() -> Self {
7678 Self {
7679 calls: Arc::new(Mutex::new(Vec::new())),
7680 }
7681 }
7682
7683 #[allow(dead_code, reason = "kept for future test extension")]
7684 fn call_count(&self) -> usize {
7685 self.calls.lock().unwrap().len()
7686 }
7687
7688 #[allow(dead_code, reason = "kept for future test extension")]
7689 fn calls(&self) -> Vec<(String, Vec<String>)> {
7690 self.calls.lock().unwrap().clone()
7691 }
7692 }
7693
7694 #[async_trait(?Send)]
7695 impl Broadcaster for MockBroadcaster {
7696 async fn broadcast_to_host(
7697 &self,
7698 host_url: &str,
7699 tagged_beef: &TaggedBEEF,
7700 ) -> Result<(), String> {
7701 self.calls
7702 .lock()
7703 .unwrap()
7704 .push((host_url.to_string(), tagged_beef.topics.clone()));
7705 Ok(())
7706 }
7707 }
7708
7709 struct MockSHIPLookupService {
7715 records: Mutex<HashMap<String, Vec<UTXOReference>>>,
7717 }
7718
7719 impl MockSHIPLookupService {
7720 fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
7721 Self {
7722 records: Mutex::new(records),
7723 }
7724 }
7725 }
7726
7727 #[async_trait(?Send)]
7728 impl LookupServiceTrait for MockSHIPLookupService {
7729 fn admission_mode(&self) -> AdmissionMode {
7730 AdmissionMode::LockingScript
7731 }
7732
7733 fn spend_notification_mode(&self) -> SpendNotificationMode {
7734 SpendNotificationMode::None
7735 }
7736
7737 async fn output_admitted_by_topic(
7738 &self,
7739 _payload: &OutputAdmittedByTopic,
7740 ) -> Result<(), LookupServiceError> {
7741 Ok(())
7742 }
7743
7744 async fn output_evicted(
7745 &self,
7746 _txid: &str,
7747 _output_index: u32,
7748 ) -> Result<(), LookupServiceError> {
7749 Ok(())
7750 }
7751
7752 async fn lookup(
7753 &self,
7754 question: &LookupQuestion,
7755 ) -> Result<LookupResult, LookupServiceError> {
7756 if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
7758 let records = self.records.lock().unwrap();
7759 let mut results = Vec::new();
7760 for topic_val in topics {
7761 if let Some(topic) = topic_val.as_str() {
7762 if let Some(refs) = records.get(topic) {
7763 results.extend(refs.iter().cloned());
7764 }
7765 }
7766 }
7767 return Ok(LookupResult::OutputList(results));
7768 }
7769 Ok(LookupResult::OutputList(vec![]))
7770 }
7771
7772 async fn get_documentation(&self) -> String {
7773 "Mock SHIP lookup service".to_string()
7774 }
7775
7776 async fn get_metadata(&self) -> ServiceMetadata {
7777 ServiceMetadata {
7778 name: "mock-ls-ship".to_string(),
7779 ..Default::default()
7780 }
7781 }
7782 }
7783
7784 fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
7786 use bsv_rs::script::templates::PushDrop;
7787 use bsv_rs::PublicKey;
7788
7789 let fields = vec![
7790 b"SHIP".to_vec(),
7791 vec![0x02; 33], domain.as_bytes().to_vec(),
7793 topic.as_bytes().to_vec(),
7794 ];
7795
7796 let pubkey = PublicKey::from_hex(
7799 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7800 )
7801 .expect("valid test pubkey");
7802
7803 let pd = PushDrop::new(pubkey, fields);
7804 pd.lock().to_binary()
7805 }
7806
7807 async fn make_engine_with_broadcaster(
7810 admit_indices: Vec<u32>,
7811 broadcaster: MockBroadcaster,
7812 ship_domains: Vec<(&str, &str)>,
7813 hosting_url: Option<&str>,
7814 ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
7815 let calls = broadcaster.calls.clone();
7816
7817 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7818 managers.insert(
7819 "tm_test".to_string(),
7820 Box::new(MockTopicManager::admitting(admit_indices)),
7821 );
7822
7823 let storage = Box::new(MemoryStorage::new());
7824
7825 let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
7829
7830 for (domain, topic) in &ship_domains {
7831 let fake_txid = format!("{:064x}", {
7833 let mut h: u64 = 0;
7834 for b in domain.bytes().chain(topic.bytes()) {
7835 h = h.wrapping_mul(31).wrapping_add(u64::from(b));
7836 }
7837 h
7838 });
7839
7840 ship_records
7842 .entry(topic.to_string())
7843 .or_default()
7844 .push(UTXOReference {
7845 txid: fake_txid.clone(),
7846 output_index: 0,
7847 });
7848
7849 let script = build_ship_pushdrop_script(domain, topic);
7851
7852 let output = Output {
7853 txid: fake_txid,
7854 output_index: 0,
7855 output_script: script,
7856 satoshis: 1,
7857 topic: "tm_ship".to_string(),
7858 spent: false,
7859 outputs_consumed: vec![],
7860 consumed_by: vec![],
7861 beef: None,
7862 block_height: None,
7863 score: None,
7864 };
7865
7866 storage.insert_output(&output).await.unwrap();
7867 }
7868
7869 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7870 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7871 lookup_services.insert(
7872 "ls_ship".to_string(),
7873 Box::new(MockSHIPLookupService::with_records(ship_records)),
7874 );
7875
7876 let config = EngineConfig {
7877 hosting_url: hosting_url.map(str::to_string),
7878 ..Default::default()
7879 };
7880
7881 let engine = Engine::with_chain_tracker(
7882 managers,
7883 lookup_services,
7884 storage,
7885 None,
7886 Some(Box::new(broadcaster)),
7887 None,
7888 config,
7889 );
7890
7891 (engine, calls)
7892 }
7893
7894 #[tokio::test]
7897 async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
7898 let broadcaster = MockBroadcaster::new();
7899 let (engine, calls) = make_engine_with_broadcaster(
7900 vec![0],
7901 broadcaster,
7902 vec![("https://peer1.example.com", "tm_test")],
7903 Some("https://self.example.com"),
7904 )
7905 .await;
7906
7907 let beef = test_tagged_beef(vec!["tm_test"]);
7908 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7909
7910 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7912
7913 let recorded = calls.lock().unwrap();
7915 assert_eq!(
7916 recorded.len(),
7917 1,
7918 "Broadcaster should be called once for the one SHIP peer"
7919 );
7920 assert_eq!(recorded[0].0, "https://peer1.example.com");
7921 }
7922
7923 #[tokio::test]
7924 async fn test_broadcaster_not_called_on_historical_tx() {
7925 let broadcaster = MockBroadcaster::new();
7926 let (engine, calls) = make_engine_with_broadcaster(
7927 vec![0],
7928 broadcaster,
7929 vec![("https://peer1.example.com", "tm_test")],
7930 None,
7931 )
7932 .await;
7933
7934 let beef = test_tagged_beef(vec!["tm_test"]);
7935 let steak = engine
7936 .submit(&beef, SubmitMode::HistoricalTx)
7937 .await
7938 .unwrap();
7939
7940 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7941
7942 assert_eq!(
7944 calls.lock().unwrap().len(),
7945 0,
7946 "Broadcaster should not be called for historical TX"
7947 );
7948 }
7949
7950 #[tokio::test]
7951 async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
7952 let broadcaster = MockBroadcaster::new();
7953 let (engine, calls) = make_engine_with_broadcaster(
7954 vec![0],
7955 broadcaster,
7956 vec![("https://peer1.example.com", "tm_test")],
7957 None,
7958 )
7959 .await;
7960
7961 let beef = test_tagged_beef(vec!["tm_test"]);
7962 let steak = engine
7963 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
7964 .await
7965 .unwrap();
7966
7967 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7968
7969 assert_eq!(
7970 calls.lock().unwrap().len(),
7971 0,
7972 "Broadcaster should not be called for historical-tx-no-spv"
7973 );
7974 }
7975
7976 #[tokio::test]
7977 async fn test_broadcaster_skips_self_hosting_url() {
7978 let broadcaster = MockBroadcaster::new();
7979 let (engine, calls) = make_engine_with_broadcaster(
7980 vec![0],
7981 broadcaster,
7982 vec![
7983 ("https://self.example.com", "tm_test"),
7984 ("https://peer2.example.com", "tm_test"),
7985 ],
7986 Some("https://self.example.com"),
7987 )
7988 .await;
7989
7990 let beef = test_tagged_beef(vec!["tm_test"]);
7991 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7992
7993 let recorded = calls.lock().unwrap();
7995 assert_eq!(
7996 recorded.len(),
7997 1,
7998 "Should broadcast to peer2 only, skipping self"
7999 );
8000 assert_eq!(recorded[0].0, "https://peer2.example.com");
8001 }
8002
8003 #[tokio::test]
8004 async fn test_broadcaster_not_called_when_no_outputs_admitted() {
8005 let broadcaster = MockBroadcaster::new();
8006 let (engine, calls) = make_engine_with_broadcaster(
8008 vec![],
8009 broadcaster,
8010 vec![("https://peer1.example.com", "tm_test")],
8011 None,
8012 )
8013 .await;
8014
8015 let beef = test_tagged_beef(vec!["tm_test"]);
8016 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8017
8018 assert!(steak["tm_test"].outputs_to_admit.is_empty());
8019
8020 assert_eq!(
8022 calls.lock().unwrap().len(),
8023 0,
8024 "Broadcaster should not be called when no outputs are admitted"
8025 );
8026 }
8027
8028 #[tokio::test]
8029 async fn test_broadcaster_not_called_when_no_ship_peers() {
8030 let broadcaster = MockBroadcaster::new();
8031 let (engine, calls) = make_engine_with_broadcaster(
8033 vec![0],
8034 broadcaster,
8035 vec![], None,
8037 )
8038 .await;
8039
8040 let beef = test_tagged_beef(vec!["tm_test"]);
8041 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8042
8043 assert_eq!(
8044 calls.lock().unwrap().len(),
8045 0,
8046 "Broadcaster should not be called when no SHIP peers exist"
8047 );
8048 }
8049
8050 struct SpendModeLookupService {
8056 mode: SpendNotificationMode,
8057 records: Mutex<Vec<UTXOReference>>,
8058 spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
8059 }
8060
8061 impl SpendModeLookupService {
8062 fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
8063 Self {
8064 mode,
8065 records: Mutex::new(Vec::new()),
8066 spent_payloads: capture,
8067 }
8068 }
8069 }
8070
8071 #[async_trait(?Send)]
8072 impl LookupServiceTrait for SpendModeLookupService {
8073 fn admission_mode(&self) -> AdmissionMode {
8074 AdmissionMode::LockingScript
8075 }
8076
8077 fn spend_notification_mode(&self) -> SpendNotificationMode {
8078 self.mode
8079 }
8080
8081 async fn output_admitted_by_topic(
8082 &self,
8083 payload: &OutputAdmittedByTopic,
8084 ) -> Result<(), LookupServiceError> {
8085 let (txid, oi) = match payload {
8086 OutputAdmittedByTopic::LockingScript {
8087 txid, output_index, ..
8088 } => (txid.clone(), *output_index),
8089 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
8090 ("whole".into(), *output_index)
8091 }
8092 };
8093 self.records.lock().unwrap().push(UTXOReference {
8094 txid,
8095 output_index: oi,
8096 });
8097 Ok(())
8098 }
8099
8100 async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
8101 self.spent_payloads.lock().unwrap().push(payload.clone());
8102 Ok(())
8103 }
8104
8105 async fn output_evicted(
8106 &self,
8107 txid: &str,
8108 output_index: u32,
8109 ) -> Result<(), LookupServiceError> {
8110 self.records
8111 .lock()
8112 .unwrap()
8113 .retain(|r| !(r.txid == txid && r.output_index == output_index));
8114 Ok(())
8115 }
8116
8117 async fn lookup(
8118 &self,
8119 _question: &LookupQuestion,
8120 ) -> Result<LookupResult, LookupServiceError> {
8121 Ok(LookupResult::OutputList(
8122 self.records.lock().unwrap().clone(),
8123 ))
8124 }
8125
8126 async fn get_documentation(&self) -> String {
8127 "SpendMode mock lookup service".to_string()
8128 }
8129
8130 async fn get_metadata(&self) -> ServiceMetadata {
8131 ServiceMetadata {
8132 name: "spend-mode-ls".to_string(),
8133 ..Default::default()
8134 }
8135 }
8136 }
8137
8138 const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
8140
8141 async fn make_spend_mode_engine(
8144 mode: SpendNotificationMode,
8145 ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
8146 let storage = MemoryStorage::new();
8147
8148 let prev_output = Output {
8150 txid: PREVIOUS_TXID.to_string(),
8151 output_index: 0,
8152 output_script: vec![0x76, 0xa9],
8153 satoshis: 26174,
8154 topic: "tm_test".to_string(),
8155 spent: false,
8156 outputs_consumed: vec![],
8157 consumed_by: vec![],
8158 beef: Some(test_beef()),
8159 block_height: None,
8160 score: Some(1000.0),
8161 };
8162 storage.insert_output(&prev_output).await.unwrap();
8163
8164 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8165 managers.insert(
8166 "tm_test".to_string(),
8167 Box::new(MockTopicManager::admitting(vec![0])),
8168 );
8169
8170 let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
8171 let ls = SpendModeLookupService::with_mode(mode, capture.clone());
8172 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8173 lookup_services.insert("ls_test".to_string(), Box::new(ls));
8174
8175 let engine = Engine::new(
8176 managers,
8177 lookup_services,
8178 Box::new(storage),
8179 None,
8180 EngineConfig::default(),
8181 );
8182
8183 (engine, capture)
8184 }
8185
8186 #[tokio::test]
8187 async fn test_spend_notification_mode_script() {
8188 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
8189
8190 let beef = test_tagged_beef(vec!["tm_test"]);
8191 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8192
8193 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8195
8196 let payloads = capture.lock().unwrap().clone();
8198 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8199
8200 match &payloads[0] {
8201 OutputSpent::Script {
8202 txid,
8203 output_index,
8204 topic,
8205 spending_txid,
8206 input_index,
8207 unlocking_script,
8208 sequence_number,
8209 ..
8210 } => {
8211 assert_eq!(txid, PREVIOUS_TXID);
8212 assert_eq!(*output_index, 0);
8213 assert_eq!(topic, "tm_test");
8214 assert_eq!(spending_txid, TEST_TXID);
8215 assert_eq!(*input_index, 0);
8217 assert!(
8219 !unlocking_script.is_empty(),
8220 "Unlocking script should be non-empty"
8221 );
8222 assert_eq!(*sequence_number, 0xffff_ffff);
8224 }
8225 other => panic!("Expected OutputSpent::Script, got: {other:?}"),
8226 }
8227 }
8228
8229 #[tokio::test]
8230 async fn test_spend_notification_mode_whole_tx() {
8231 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
8232
8233 let beef = test_tagged_beef(vec!["tm_test"]);
8234 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8235
8236 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8237
8238 let payloads = capture.lock().unwrap().clone();
8239 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8240
8241 match &payloads[0] {
8242 OutputSpent::WholeTx {
8243 txid,
8244 output_index,
8245 topic,
8246 spending_atomic_beef,
8247 ..
8248 } => {
8249 assert_eq!(txid, PREVIOUS_TXID);
8250 assert_eq!(*output_index, 0);
8251 assert_eq!(topic, "tm_test");
8252 let mut named =
8255 bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
8256 assert!(named.is_atomic());
8257 let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
8258 assert_eq!(
8259 named.txs.len(),
8260 submitted.txs.len(),
8261 "the whole body, never pruned"
8262 );
8263 let tip = crate::subject::subject_txid_of(&mut named).unwrap();
8264 assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
8265 }
8266 other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
8267 }
8268 }
8269
8270 #[tokio::test]
8271 async fn test_spend_notification_mode_txid() {
8272 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
8273
8274 let beef = test_tagged_beef(vec!["tm_test"]);
8275 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8276
8277 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8278
8279 let payloads = capture.lock().unwrap().clone();
8280 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8281
8282 match &payloads[0] {
8283 OutputSpent::Txid {
8284 txid,
8285 output_index,
8286 topic,
8287 spending_txid,
8288 } => {
8289 assert_eq!(txid, PREVIOUS_TXID);
8290 assert_eq!(*output_index, 0);
8291 assert_eq!(topic, "tm_test");
8292 assert_eq!(spending_txid, TEST_TXID);
8293 }
8294 other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
8295 }
8296 }
8297
8298 #[tokio::test]
8299 async fn test_spend_notification_mode_none() {
8300 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
8301
8302 let beef = test_tagged_beef(vec!["tm_test"]);
8303 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8304
8305 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8306
8307 let payloads = capture.lock().unwrap().clone();
8308 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8309
8310 match &payloads[0] {
8311 OutputSpent::None {
8312 txid,
8313 output_index,
8314 topic,
8315 } => {
8316 assert_eq!(txid, PREVIOUS_TXID);
8317 assert_eq!(*output_index, 0);
8318 assert_eq!(topic, "tm_test");
8319 }
8320 other => panic!("Expected OutputSpent::None, got: {other:?}"),
8321 }
8322 }
8323
8324 #[tokio::test]
8327 async fn test_start_gasp_sync_empty_config() {
8328 let engine = Engine::new(
8329 HashMap::new(),
8330 HashMap::new(),
8331 Box::new(MemoryStorage::new()),
8332 None,
8333 EngineConfig {
8334 sync_configuration: HashMap::new(),
8335 ..Default::default()
8336 },
8337 );
8338
8339 let result = engine.start_gasp_sync().await.unwrap();
8340 assert!(
8341 result.topics_synced.is_empty(),
8342 "No topics should be synced with empty config"
8343 );
8344 }
8345
8346 #[tokio::test]
8347 async fn test_start_gasp_sync_disabled_topic_skipped() {
8348 let mut sync_config: SyncConfiguration = HashMap::new();
8349 sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
8350
8351 let engine = Engine::new(
8352 HashMap::new(),
8353 HashMap::new(),
8354 Box::new(MemoryStorage::new()),
8355 None,
8356 EngineConfig {
8357 sync_configuration: sync_config,
8358 ..Default::default()
8359 },
8360 );
8361
8362 let result = engine.start_gasp_sync().await.unwrap();
8363 assert!(
8364 result.topics_synced.is_empty(),
8365 "Disabled topics should not appear in results"
8366 );
8367 }
8368
8369 #[tokio::test]
8370 async fn test_start_gasp_sync_peers_config() {
8371 let mut sync_config: SyncConfiguration = HashMap::new();
8372 sync_config.insert(
8373 "tm_test".to_string(),
8374 SyncTarget::Peers(vec![
8375 "https://peer1.example.com".to_string(),
8376 "https://peer2.example.com".to_string(),
8377 ]),
8378 );
8379
8380 let engine = Engine::new(
8381 HashMap::new(),
8382 HashMap::new(),
8383 Box::new(MemoryStorage::new()),
8384 None,
8385 EngineConfig {
8386 sync_configuration: sync_config,
8387 ..Default::default()
8388 },
8389 );
8390
8391 let result = engine.start_gasp_sync().await.unwrap();
8392 assert_eq!(result.topics_synced.len(), 1);
8393
8394 let topic_result = &result.topics_synced["tm_test"];
8395 assert_eq!(topic_result.peers.len(), 2);
8396 assert_eq!(topic_result.sync_type, "peers");
8397 assert!(topic_result.errors.is_empty());
8398 }
8399
8400 #[tokio::test]
8401 async fn test_start_gasp_sync_peers_filters_self() {
8402 let mut sync_config: SyncConfiguration = HashMap::new();
8403 sync_config.insert(
8404 "tm_test".to_string(),
8405 SyncTarget::Peers(vec![
8406 "https://self.example.com".to_string(),
8407 "https://peer2.example.com".to_string(),
8408 ]),
8409 );
8410
8411 let engine = Engine::new(
8412 HashMap::new(),
8413 HashMap::new(),
8414 Box::new(MemoryStorage::new()),
8415 None,
8416 EngineConfig {
8417 hosting_url: Some("https://self.example.com".to_string()),
8418 sync_configuration: sync_config,
8419 ..Default::default()
8420 },
8421 );
8422
8423 let result = engine.start_gasp_sync().await.unwrap();
8424 let topic_result = &result.topics_synced["tm_test"];
8425 assert_eq!(
8426 topic_result.peers.len(),
8427 1,
8428 "Self URL should be filtered out"
8429 );
8430 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
8431 }
8432
8433 #[tokio::test]
8434 async fn load_0112_quiet_peer_costs_no_request_during_backoff() {
8435 let calls = std::rc::Rc::default();
8436 let store = std::rc::Rc::new(MemoryStorage::new());
8437 let mut engine = Engine::new(
8438 HashMap::new(),
8439 HashMap::new(),
8440 Box::new(store.clone()),
8441 None,
8442 EngineConfig {
8443 sync_configuration: HashMap::from([(
8444 "tm_test".into(),
8445 SyncTarget::Peers(vec!["https://quiet.example".into()]),
8446 )]),
8447 ..Default::default()
8448 },
8449 );
8450 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8451 calls: std::rc::Rc::clone(&calls),
8452 }));
8453 for _ in 0..4 {
8454 engine.start_gasp_sync().await.unwrap();
8455 }
8456 assert_eq!(calls.borrow().len(), 3);
8457 assert_eq!(
8458 store
8459 .get_peer_sync_health("quiet.example", "tm_test")
8460 .await
8461 .unwrap()
8462 .consecutive_failures,
8463 0
8464 );
8465 store.advance_clock(60);
8466 engine.start_gasp_sync().await.unwrap();
8467 assert_eq!(calls.borrow().len(), 4);
8468 engine.start_gasp_sync().await.unwrap();
8469 assert_eq!(calls.borrow().len(), 4);
8470 engine
8471 .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8472 topics: Some(vec!["tm_test".into()]),
8473 peers: None,
8474 })
8475 .await
8476 .unwrap();
8477 assert_eq!(calls.borrow().len(), 5);
8478 engine.start_gasp_sync().await.unwrap();
8479 assert_eq!(
8480 calls.borrow().len(),
8481 6,
8482 "explicit selection reset the quiet streak"
8483 );
8484 }
8485
8486 #[tokio::test]
8487 async fn load_0112_yield_and_advertisement_reset_cadence() {
8488 let (mut engine, remote) = budget_page_engine(1);
8489 engine.set_gasp_remote_factory(Box::new(remote));
8490 engine
8491 .storage
8492 .set_peer_cadence(
8493 "budget.example",
8494 "tm_test",
8495 &crate::sync_load::PeerCadence {
8496 quiet_syncs: 8,
8497 retry_after_secs: 0,
8498 advertisement: String::new(),
8499 },
8500 )
8501 .await
8502 .unwrap();
8503 let result = engine.start_gasp_sync().await.unwrap();
8504 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
8505 assert_eq!(
8506 engine
8507 .storage
8508 .get_peer_cadence("budget.example", "tm_test")
8509 .await
8510 .unwrap()
8511 .quiet_syncs,
8512 0
8513 );
8514 engine
8515 .storage
8516 .set_peer_cadence(
8517 "budget.example",
8518 "tm_test",
8519 &crate::sync_load::PeerCadence {
8520 quiet_syncs: 8,
8521 retry_after_secs: 3_600,
8522 advertisement: "previous-advertisement".into(),
8523 },
8524 )
8525 .await
8526 .unwrap();
8527 let result = engine.start_gasp_sync().await.unwrap();
8528 assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8529 assert!(
8530 engine
8531 .storage
8532 .get_peer_cadence("budget.example", "tm_test")
8533 .await
8534 .unwrap()
8535 .quiet_syncs
8536 <= 1
8537 );
8538 }
8539
8540 #[tokio::test]
8541 async fn load_0112_an_admitted_slap_change_wakes_its_peer() {
8542 let (mut engine, mut remote) = budget_page_engine(0);
8543 remote.node_cost_ms = 0;
8544 engine.set_gasp_remote_factory(Box::new(remote.clone()));
8545 for _ in 0..3 {
8546 engine.start_gasp_sync().await.unwrap();
8547 }
8548 let skipped = engine.start_gasp_sync().await.unwrap();
8549 assert_eq!(
8550 skipped.topics_synced["tm_test"].peers_skipped[0].reason,
8551 "no_yield"
8552 );
8553 let count = remote.requests.borrow().len();
8554 let ad = discovery_output("SLAP", "https://budget.example", "ls_ship");
8555 let tx = beef_limits::transaction_from_beef(&ad.beef, None, &beef_limits::PEER_BEEF_LIMITS)
8556 .unwrap();
8557 engine
8558 .storage
8559 .insert_output(&Output {
8560 txid: tx.id(),
8561 output_index: 0,
8562 output_script: tx.outputs[0].locking_script.to_binary(),
8563 satoshis: 1,
8564 topic: "tm_slap".into(),
8565 spent: false,
8566 outputs_consumed: vec![],
8567 consumed_by: vec![],
8568 beef: None,
8569 block_height: None,
8570 score: Some(1.0),
8571 })
8572 .await
8573 .unwrap();
8574 let result = engine.start_gasp_sync().await.unwrap();
8575 assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8576 assert_eq!(remote.requests.borrow().len(), count + 1);
8577 }
8578
8579 #[tokio::test]
8580 async fn load_0112_filter_selects_only_named_topic_and_peer() {
8581 let calls = std::rc::Rc::default();
8582 let mut engine = Engine::new(
8583 HashMap::new(),
8584 HashMap::new(),
8585 Box::new(MemoryStorage::new()),
8586 None,
8587 EngineConfig {
8588 sync_configuration: ["tm_alpha", "tm_beta"]
8589 .into_iter()
8590 .map(|t| {
8591 (
8592 t.to_string(),
8593 SyncTarget::Peers(vec![
8594 "https://a.example".into(),
8595 "https://b.example".into(),
8596 ]),
8597 )
8598 })
8599 .collect(),
8600 ..Default::default()
8601 },
8602 );
8603 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8604 calls: std::rc::Rc::clone(&calls),
8605 }));
8606 let filter = crate::sync_load::SyncFilter::parse(
8607 r#"{"topics":["tm_beta"],"peers":["https://b.example"]}"#,
8608 )
8609 .unwrap();
8610 let result = engine.start_gasp_sync_filtered(&filter).await.unwrap();
8611 assert_eq!(
8612 *calls.borrow(),
8613 vec![("https://b.example".into(), "tm_beta".into())]
8614 );
8615 assert_eq!(result.topics_synced.len(), 1);
8616 calls.borrow_mut().clear();
8617 engine.start_gasp_sync().await.unwrap();
8618 assert_eq!(calls.borrow().len(), 4);
8619 calls.borrow_mut().clear();
8620 engine
8621 .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8622 topics: Some(vec![]),
8623 peers: None,
8624 })
8625 .await
8626 .unwrap();
8627 assert!(calls.borrow().is_empty());
8628 assert!(crate::sync_load::SyncFilter::parse(r#"{"peers":"bad"}"#).is_err());
8629 assert!(crate::sync_load::SyncFilter::parse("null").is_err());
8630 assert_eq!(
8631 crate::sync_load::SyncFilter::parse(" ").unwrap(),
8632 crate::sync_load::SyncFilter::default()
8633 );
8634 }
8635
8636 #[derive(Clone, Default)]
8637 struct MockShipLookupClient {
8638 records: HashMap<(String, String), Vec<OutputListItem>>,
8639 failures: HashSet<String>,
8640 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
8641 }
8642
8643 struct RecordingDiscoverySyncFactory {
8644 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
8645 }
8646
8647 impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
8648 fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8649 self.calls
8650 .borrow_mut()
8651 .push((peer.to_string(), topic.to_string()));
8652 Box::new(MockSyncRemote { utxos: Vec::new() })
8653 }
8654 }
8655
8656 #[async_trait(?Send)]
8657 impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
8658 async fn lookup(
8659 &self,
8660 host: &str,
8661 question: &LookupQuestion,
8662 ) -> Result<Vec<OutputListItem>, String> {
8663 self.calls
8664 .borrow_mut()
8665 .push((host.to_string(), question.clone()));
8666 if self.failures.contains(host) {
8667 return Err("mock tracker unavailable".to_string());
8668 }
8669 Ok(self
8670 .records
8671 .get(&(host.to_string(), question.service.clone()))
8672 .cloned()
8673 .unwrap_or_default())
8674 }
8675 }
8676
8677 fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
8678 use bsv_rs::script::templates::PushDrop;
8679 let pubkey = bsv_rs::PublicKey::from_hex(
8680 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
8681 )
8682 .unwrap();
8683 let script = PushDrop::new(
8684 pubkey,
8685 vec![
8686 protocol.as_bytes().to_vec(),
8687 vec![0x02; 33],
8688 domain.as_bytes().to_vec(),
8689 scope.as_bytes().to_vec(),
8690 ],
8691 )
8692 .lock();
8693 let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
8694 tx.outputs[0].locking_script = script;
8695 tx.merkle_path = None;
8696 tx.invalidate_caches();
8697 let beef = tx.to_atomic_beef(true).unwrap();
8698 let parsed =
8699 beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
8700 .unwrap();
8701 assert_eq!(
8702 parsed.outputs[0].locking_script,
8703 tx.outputs[0].locking_script
8704 );
8705 OutputListItem {
8706 beef,
8707 output_index: 0,
8708 context: None,
8709 }
8710 }
8711
8712 #[tokio::test]
8713 async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
8714 let (mut engine, _) = make_engine_with_broadcaster(
8715 vec![0],
8716 MockBroadcaster::new(),
8717 vec![
8718 ("https://local.example", "tm_test"),
8719 ("https://shared.example/?local", "tm_test"),
8720 ],
8721 Some("https://self.example"),
8722 )
8723 .await;
8724 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8725 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8726 let mut client = MockShipLookupClient::default();
8727 client.records.insert(
8728 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8729 vec![
8730 discovery_output("SHIP", "https://direct.example", "tm_test"),
8731 discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
8732 discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
8733 discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
8734 discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
8735 OutputListItem {
8736 beef: vec![1, 2, 3],
8737 output_index: 0,
8738 context: None,
8739 },
8740 ],
8741 );
8742 client.records.insert(
8743 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8744 vec![
8745 discovery_output("SLAP", "https://lookup.example", "ls_ship"),
8746 discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
8747 discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
8748 discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
8749 discovery_output("SLAP", "https://other-service.example", "ls_other"),
8750 ],
8751 );
8752 client.records.insert(
8753 ("https://lookup.example".to_string(), "ls_ship".to_string()),
8754 vec![
8755 discovery_output("SHIP", "https://indirect.example", "tm_test"),
8756 discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
8757 ],
8758 );
8759 let calls = client.calls.clone();
8760 engine.set_ship_lookup_client(Box::new(client));
8761 let result = engine.start_gasp_sync().await.unwrap();
8762 assert_eq!(
8763 result.topics_synced["tm_test"].peers,
8764 vec![
8765 "https://bootstrap.example",
8766 "https://direct.example",
8767 "https://indirect.example",
8768 "https://local.example",
8769 "https://shared.example",
8770 ]
8771 );
8772 let calls = calls.borrow();
8773 assert_eq!(calls.len(), 3);
8774 for (_, question) in calls.iter() {
8775 let expected = if question.service == "ls_slap" {
8776 serde_json::json!({"service": "ls_ship"})
8777 } else {
8778 serde_json::json!({"topics": ["tm_test"]})
8779 };
8780 assert_eq!(question.query, expected);
8781 }
8782 }
8783
8784 #[tokio::test]
8785 async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
8786 let (mut engine, _) =
8787 make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
8788 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8789 let mut client = MockShipLookupClient::default();
8790 let outputs = (0..9)
8791 .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
8792 .collect();
8793 client.records.insert(
8794 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8795 outputs,
8796 );
8797 engine.set_ship_lookup_client(Box::new(client));
8798 let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8799 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8800 calls: sync_calls.clone(),
8801 }));
8802 let result = engine.start_gasp_sync().await.unwrap();
8803 let topic = &result.topics_synced["tm_test"];
8804 assert_eq!(topic.sync_type, "ship");
8805 assert_eq!(
8806 topic.peers.len(),
8807 9,
8808 "the lookup-host cap must not truncate sync peers"
8809 );
8810 assert!(topic.errors.is_empty());
8811 assert_eq!(sync_calls.borrow().len(), 9);
8812 for peer in &topic.peers {
8813 assert!(sync_calls
8814 .borrow()
8815 .contains(&(peer.clone(), "tm_test".to_string())));
8816 }
8817 }
8818
8819 #[tokio::test]
8820 async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
8821 let mut engine = make_engine(vec![]);
8822 engine.config.slap_trackers = vec![
8823 "https://dead.example".to_string(),
8824 "https://live.example".to_string(),
8825 ];
8826 let mut client = MockShipLookupClient::default();
8827 client.failures.insert("https://dead.example".to_string());
8828 client.records.insert(
8829 ("https://live.example".to_string(), "ls_ship".to_string()),
8830 vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
8831 );
8832 engine.set_ship_lookup_client(Box::new(client));
8833 let result = engine.start_gasp_sync().await.unwrap();
8834 assert_eq!(
8835 result.topics_synced["tm_test"].peers,
8836 vec!["https://peer.example"]
8837 );
8838 }
8839
8840 #[tokio::test]
8841 async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
8842 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
8843 let mut engine = make_engine(vec![]);
8844 engine
8845 .config
8846 .sync_configuration
8847 .insert("tm_other".to_string(), SyncTarget::Ship);
8848 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8849 let mut client = MockShipLookupClient::default();
8850 let mut providers = Vec::new();
8851 for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
8852 let host = format!("https://lookup{n}.example");
8853 providers.push(discovery_output("SLAP", &host, "ls_ship"));
8854 client.records.insert(
8855 (host, "ls_ship".to_string()),
8856 vec![
8857 discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
8858 discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
8859 ],
8860 );
8861 }
8862 client.records.insert(
8863 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8864 providers,
8865 );
8866 let calls = client.calls.clone();
8867 engine.set_ship_lookup_client(Box::new(client));
8868 let result = engine.start_gasp_sync().await.unwrap();
8869 assert_eq!(
8870 result.topics_synced["tm_test"].peers.len(),
8871 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8872 );
8873 assert_eq!(
8874 result.topics_synced["tm_other"].peers.len(),
8875 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8876 );
8877 let calls = calls.borrow();
8878 assert_eq!(
8879 calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
8880 1
8881 );
8882 let providers: HashSet<_> = calls
8883 .iter()
8884 .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
8885 .map(|(h, _)| h)
8886 .collect();
8887 assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
8888 assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
8889 }
8890
8891 #[tokio::test]
8892 async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
8893 let mut engine = make_engine(vec![]);
8894 engine.config.hosting_url = Some("https://self.example".to_string());
8895 engine.config.slap_trackers = vec!["https://dead.example".to_string()];
8896 let mut client = MockShipLookupClient::default();
8897 client.failures.insert("https://dead.example".to_string());
8898 engine.set_ship_lookup_client(Box::new(client));
8899 engine.set_ship_bootstrap_peers(
8900 "tm_test",
8901 vec![
8902 "https://bootstrap.example".to_string(),
8903 "https://BOOTSTRAP.example.:443/?x".to_string(),
8904 "http://SELF.example.:8443/path".to_string(),
8905 ],
8906 );
8907 let result = engine.start_gasp_sync().await.unwrap();
8908 assert_eq!(
8909 result.topics_synced["tm_test"].peers,
8910 vec!["https://bootstrap.example"]
8911 );
8912 }
8913
8914 #[tokio::test]
8915 async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
8916 let mut engine = make_engine(vec![]);
8917 for topic in ["tm_ship", "tm_slap"] {
8918 engine.config.sync_configuration.insert(
8919 topic.to_string(),
8920 SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
8921 );
8922 }
8923 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8924 let mut client = MockShipLookupClient::default();
8925 client.records.insert(
8926 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8927 vec![discovery_output(
8928 "SHIP",
8929 "https://discovered.example",
8930 "tm_test",
8931 )],
8932 );
8933 let lookups = client.calls.clone();
8934 engine.set_ship_lookup_client(Box::new(client));
8935 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8936 let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8937 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8938 calls: calls.clone(),
8939 }));
8940 engine.start_gasp_sync().await.unwrap();
8941 assert_eq!(
8942 calls
8943 .borrow()
8944 .iter()
8945 .map(|(_, topic)| topic.as_str())
8946 .collect::<Vec<_>>(),
8947 vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
8948 );
8949 assert_eq!(lookups.borrow().len(), 2);
8950 }
8951
8952 #[tokio::test]
8953 async fn test_start_gasp_sync_ship_discovers_peers() {
8954 let broadcaster = MockBroadcaster::new();
8955 let (engine, _calls) = make_engine_with_broadcaster(
8956 vec![0],
8957 broadcaster,
8958 vec![
8959 ("https://peer1.example.com", "tm_test"),
8960 ("https://peer2.example.com", "tm_test"),
8961 ],
8962 Some("https://self.example.com"),
8963 )
8964 .await;
8965
8966 let result = engine.start_gasp_sync().await.unwrap();
8967 assert!(
8968 result.topics_synced.contains_key("tm_test"),
8969 "tm_test should be in sync results"
8970 );
8971
8972 let topic_result = &result.topics_synced["tm_test"];
8973 assert_eq!(topic_result.sync_type, "ship");
8974 assert_eq!(
8975 topic_result.peers.len(),
8976 2,
8977 "Should discover both SHIP peers"
8978 );
8979 assert!(topic_result
8980 .peers
8981 .contains(&"https://peer1.example.com".to_string()));
8982 assert!(topic_result
8983 .peers
8984 .contains(&"https://peer2.example.com".to_string()));
8985 }
8986
8987 #[tokio::test]
8988 async fn test_start_gasp_sync_ship_filters_self() {
8989 let broadcaster = MockBroadcaster::new();
8990 let (engine, _calls) = make_engine_with_broadcaster(
8991 vec![0],
8992 broadcaster,
8993 vec![
8994 ("https://self.example.com", "tm_test"),
8995 ("https://peer2.example.com", "tm_test"),
8996 ],
8997 Some("https://self.example.com"),
8998 )
8999 .await;
9000
9001 let result = engine.start_gasp_sync().await.unwrap();
9002 let topic_result = &result.topics_synced["tm_test"];
9003 assert_eq!(
9004 topic_result.peers.len(),
9005 1,
9006 "Self URL should be filtered out from SHIP discovery"
9007 );
9008 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
9009 }
9010
9011 #[tokio::test]
9012 async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
9013 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9014 managers.insert(
9015 "tm_custom".to_string(),
9016 Box::new(MockTopicManager::admitting(vec![])),
9017 );
9018
9019 let mut sync_config: SyncConfiguration = HashMap::new();
9020 sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
9021
9022 let engine = Engine::new(
9023 managers,
9024 HashMap::new(),
9025 Box::new(MemoryStorage::new()),
9026 None,
9027 EngineConfig {
9028 sync_configuration: sync_config,
9029 ..Default::default()
9030 },
9031 );
9032
9033 let result = engine.start_gasp_sync().await.unwrap();
9034 let topic_result = &result.topics_synced["tm_custom"];
9035 assert!(
9036 topic_result.peers.is_empty(),
9037 "Without ls_ship, SHIP discovery should return no peers"
9038 );
9039 }
9040
9041 #[tokio::test]
9042 async fn test_start_gasp_sync_mixed_config() {
9043 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9044 managers.insert(
9045 "tm_peers".to_string(),
9046 Box::new(MockTopicManager::admitting(vec![])),
9047 );
9048 managers.insert(
9049 "tm_disabled".to_string(),
9050 Box::new(MockTopicManager::admitting(vec![])),
9051 );
9052 managers.insert(
9053 "tm_ship_topic".to_string(),
9054 Box::new(MockTopicManager::admitting(vec![])),
9055 );
9056
9057 let mut sync_config: SyncConfiguration = HashMap::new();
9058 sync_config.insert(
9059 "tm_peers".to_string(),
9060 SyncTarget::Peers(vec!["https://peer.com".to_string()]),
9061 );
9062 sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
9063 sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
9064
9065 let engine = Engine::new(
9066 managers,
9067 HashMap::new(),
9068 Box::new(MemoryStorage::new()),
9069 None,
9070 EngineConfig {
9071 sync_configuration: sync_config,
9072 ..Default::default()
9073 },
9074 );
9075
9076 let result = engine.start_gasp_sync().await.unwrap();
9077 assert_eq!(result.topics_synced.len(), 2);
9078 assert!(result.topics_synced.contains_key("tm_peers"));
9079 assert!(result.topics_synced.contains_key("tm_ship_topic"));
9080 assert!(!result.topics_synced.contains_key("tm_disabled"));
9081
9082 assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
9083 assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
9084 assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
9085 }
9086
9087 #[tokio::test]
9088 async fn test_gasp_sync_result_serialization() {
9089 let mut topics_synced = HashMap::new();
9090 topics_synced.insert(
9091 "tm_test".to_string(),
9092 super::TopicSyncResult {
9093 cost: crate::meter::SyncCost::default(),
9094 root_bounds: Vec::new(),
9095 peers_skipped: Vec::new(),
9096 peers_filtered: Vec::new(),
9097 roots_proven: 0,
9098 roots_unproven: 0,
9099 cursor_held_by: Vec::new(),
9100 expired_refused: 0,
9101 peers: vec!["https://peer.com".to_string()],
9102 sync_type: "ship".to_string(),
9103 errors: vec![],
9104 pruned_inputs: 0,
9105 discarded_graphs: 0,
9106 anchor_walk_held_graphs: 0,
9107 finalized_graphs: 0,
9108 deadline_dropped_graphs: 0,
9109 cursor_moves: Vec::new(),
9110 deferred_graphs: 0,
9111 resumed_graphs: 0,
9112 converged_graphs: 0,
9113 dropped_graphs: Vec::new(),
9114 stalled_graphs: 0,
9115 held_back_graphs: 0,
9116 backed_off_graphs: 0,
9117 waiting_graphs: 0,
9118 idle_counted: 0,
9119 peer_results: HashMap::new(),
9120 dead_graphs: 0,
9121 expired_swept: 0,
9122 unanchorable_graphs: 0,
9123 unanchorable_cleared: 0,
9124 strikes_refused: 0,
9125 faulted_roots: Vec::new(),
9126 },
9127 );
9128
9129 let result = super::GASPSyncResult {
9130 cost: crate::meter::SyncCost::default(),
9131 topics_synced,
9132 filter: crate::sync_load::SyncFilter::default(),
9133 topics_filtered: Vec::new(),
9134 };
9135 let json = serde_json::to_string(&result).unwrap();
9136 let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
9137
9138 assert_eq!(back.topics_synced.len(), 1);
9139 assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
9140 assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
9141 }
9142
9143 struct MockSyncRemote {
9147 utxos: Vec<crate::types::GASPOutput>,
9148 }
9149
9150 #[async_trait(?Send)]
9151 impl crate::gasp::GASPRemote for MockSyncRemote {
9152 async fn get_initial_response(
9153 &self,
9154 request: &crate::types::GASPInitialRequest,
9155 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9156 let utxos: Vec<crate::types::GASPOutput> = self
9157 .utxos
9158 .iter()
9159 .filter(|u| u.score as u64 >= request.since)
9160 .cloned()
9161 .collect();
9162 Ok(crate::types::GASPInitialResponse {
9163 utxo_list: utxos,
9164 since: request.since,
9165 })
9166 }
9167 async fn get_initial_reply(
9168 &self,
9169 _: &crate::types::GASPInitialResponse,
9170 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9171 Ok(crate::types::GASPInitialReply {
9172 utxo_list: Vec::new(),
9173 })
9174 }
9175 async fn request_node(
9176 &self,
9177 graph_id: &str,
9178 txid: &str,
9179 output_index: u32,
9180 _: bool,
9181 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9182 Ok(crate::types::GASPNode {
9183 graph_id: graph_id.to_string(),
9184 raw_tx: format!("rawtx_{txid}"),
9185 output_index,
9186 proof: None,
9187 tx_metadata: None,
9188 output_metadata: None,
9189 inputs: None,
9190 })
9191 }
9192 async fn submit_node(
9193 &self,
9194 _: &crate::types::GASPNode,
9195 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9196 Ok(None)
9197 }
9198 }
9199
9200 struct MockGASPRemoteFactory;
9202
9203 #[derive(Clone)]
9204 struct BudgetPageRemote {
9205 outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
9206 nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
9207 elapsed: std::rc::Rc<Cell<u64>>,
9208 requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
9209 clamp: Option<usize>,
9210 node_cost_ms: u64,
9211 slow_once: std::rc::Rc<std::cell::RefCell<Option<(String, u64)>>>,
9213 asked: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
9215 }
9216
9217 #[async_trait(?Send)]
9218 impl crate::gasp::GASPRemote for BudgetPageRemote {
9219 async fn get_initial_response(
9220 &self,
9221 request: &crate::types::GASPInitialRequest,
9222 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9223 self.requests.borrow_mut().push(request.clone());
9224 let page = self
9225 .outputs
9226 .iter()
9227 .filter(|u| u.score as u64 >= request.since);
9228 let limit = self.clamp.map_or(usize::MAX, |cap| {
9229 cap.min(request.limit.unwrap_or(u64::MAX) as usize)
9230 });
9231 Ok(crate::types::GASPInitialResponse {
9232 utxo_list: page.take(limit).cloned().collect(),
9233 since: request.since,
9234 })
9235 }
9236
9237 async fn get_initial_reply(
9238 &self,
9239 _: &crate::types::GASPInitialResponse,
9240 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9241 unreachable!()
9242 }
9243
9244 async fn request_node(
9245 &self,
9246 graph_id: &str,
9247 txid: &str,
9248 _: u32,
9249 _: bool,
9250 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9251 self.asked.borrow_mut().push(txid.to_string());
9252 self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
9253 let slow = {
9254 let mut slow = self.slow_once.borrow_mut();
9255 match slow.as_ref() {
9256 Some((t, _)) if t == txid => slow.take().map(|(_, ms)| ms),
9257 _ => None,
9258 }
9259 };
9260 if let Some(ms) = slow {
9261 self.elapsed.set(self.elapsed.get() + ms);
9262 }
9263 if self.node_cost_ms > 0 {
9264 let mut yielded = false;
9265 std::future::poll_fn(|cx| {
9266 if yielded {
9267 std::task::Poll::Ready(())
9268 } else {
9269 yielded = true;
9270 cx.waker().wake_by_ref();
9271 std::task::Poll::Pending
9272 }
9273 })
9274 .await;
9275 }
9276 let mut node = self
9277 .nodes
9278 .get(txid)
9279 .cloned()
9280 .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
9281 node.graph_id = graph_id.to_string();
9282 Ok(node)
9283 }
9284
9285 async fn submit_node(
9286 &self,
9287 _: &crate::types::GASPNode,
9288 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9289 unreachable!()
9290 }
9291 }
9292
9293 impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
9294 fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
9295 Box::new(self.clone())
9296 }
9297 }
9298
9299 fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
9300 let mut engine = make_engine(vec![0]);
9301 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
9302 let mut outputs = Vec::new();
9303 let mut nodes = HashMap::new();
9304 for n in 1..=count {
9305 let tx = child_of(&format!("{n:064x}"), 0);
9306 let txid = tx.id();
9307 tracker.add_root(n, txid.clone());
9308 outputs.push(crate::types::GASPOutput {
9309 txid: txid.clone(),
9310 output_index: 0,
9311 score: f64::from(n * 10),
9312 });
9313 nodes.insert(
9314 txid.clone(),
9315 crate::types::GASPNode {
9316 graph_id: String::new(),
9317 raw_tx: tx.to_hex(),
9318 output_index: 0,
9319 proof: Some(single_leaf_bump_hex(&txid, n)),
9320 tx_metadata: None,
9321 output_metadata: None,
9322 inputs: None,
9323 },
9324 );
9325 }
9326 engine.chain_tracker = Some(Box::new(tracker));
9327 engine.config.sync_configuration.insert(
9328 "tm_test".to_string(),
9329 SyncTarget::Peers(vec!["https://budget.example".to_string()]),
9330 );
9331 let remote = BudgetPageRemote {
9332 outputs: std::rc::Rc::new(outputs),
9333 nodes: std::rc::Rc::new(nodes),
9334 elapsed: std::rc::Rc::new(Cell::new(0)),
9335 requests: std::rc::Rc::default(),
9336 clamp: None,
9337 node_cost_ms: 5000,
9338 slow_once: std::rc::Rc::default(),
9339 asked: std::rc::Rc::default(),
9340 };
9341 (engine, remote)
9342 }
9343
9344 fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
9345 let elapsed = remote.elapsed.clone();
9346 engine.set_peer_sync_budget(
9347 std::rc::Rc::new(move |ms| {
9348 assert_eq!(ms, 30_000);
9349 let elapsed = elapsed.clone();
9350 let until = elapsed.get() + ms;
9351 Box::pin(std::future::poll_fn(move |_| {
9352 if elapsed.get() >= until {
9353 std::task::Poll::Ready(())
9354 } else {
9355 std::task::Poll::Pending
9356 }
9357 }))
9358 }),
9359 30_000,
9360 );
9361 }
9362
9363 #[tokio::test]
9364 async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
9365 let (mut engine, remote) = budget_page_engine(3000);
9368 virtual_peer_budget(&mut engine, &remote);
9369 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9370 for round in 1..=2 {
9371 let result = engine.start_gasp_sync().await.unwrap();
9372 let topic = &result.topics_synced["tm_test"];
9373 assert_eq!(topic.finalized_graphs, 5);
9374 assert_eq!(topic.deadline_dropped_graphs, 1);
9375 assert_eq!(topic.cursor_moves.len(), 1);
9376 let expected = round * 50;
9377 assert_eq!(topic.cursor_moves[0].to, expected);
9378 assert_eq!(
9379 engine
9380 .storage
9381 .get_last_interaction("https://budget.example", "tm_test")
9382 .await
9383 .unwrap(),
9384 expected
9385 );
9386 {
9387 let requests = remote.requests.borrow();
9388 assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
9389 }
9390 for output in remote.outputs.iter().take((round * 5) as usize) {
9391 assert!(engine
9392 .storage
9393 .find_output(&output.txid, 0, Some("tm_test"), None, true)
9394 .await
9395 .unwrap()
9396 .is_some());
9397 }
9398 let unfinished = &remote.outputs[(round * 5) as usize];
9399 assert!(engine
9400 .storage
9401 .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
9402 .await
9403 .unwrap()
9404 .is_none());
9405 }
9406 }
9407
9408 #[tokio::test]
9409 async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
9410 let (mut engine, mut remote) = budget_page_engine(7);
9411 remote.clamp = Some(3);
9412 remote.node_cost_ms = 0;
9413 virtual_peer_budget(&mut engine, &remote);
9414 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9415 let result = engine.start_gasp_sync().await.unwrap();
9416 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
9417 let requests = remote.requests.borrow();
9418 assert_eq!(requests.len(), 4);
9419 assert!(requests.iter().all(|request| request.limit == Some(100)));
9420 assert_eq!(
9421 requests.iter().map(|r| r.since).collect::<Vec<_>>(),
9422 vec![0, 30, 50, 70]
9423 );
9424 }
9425
9426 #[tokio::test]
9427 async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
9428 let (mut engine, mut remote) = budget_page_engine(3000);
9429 std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
9430 virtual_peer_budget(&mut engine, &remote);
9431 engine.set_gasp_remote_factory(Box::new(remote));
9432 let result = engine.start_gasp_sync().await.unwrap();
9433 let topic = &result.topics_synced["tm_test"];
9434 assert_eq!(topic.finalized_graphs, 5);
9435 assert!(topic.cursor_moves.is_empty());
9438 }
9439
9440 #[tokio::test]
9441 async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
9442 let (mut engine, mut remote) = budget_page_engine(3);
9443 std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
9444 remote.node_cost_ms = 10_000;
9445 virtual_peer_budget(&mut engine, &remote);
9446 engine.set_gasp_remote_factory(Box::new(remote));
9447 let result = engine.start_gasp_sync().await.unwrap();
9448 let topic = &result.topics_synced["tm_test"];
9449 assert_eq!(topic.finalized_graphs, 2);
9450 assert_eq!(topic.cursor_moves[0].to, 10);
9451 }
9452
9453 #[tokio::test]
9454 async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
9455 let (mut engine, mut remote) = budget_page_engine(3000);
9456 let failed = remote.outputs[2].txid.clone();
9457 std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
9458 virtual_peer_budget(&mut engine, &remote);
9459 engine.set_gasp_remote_factory(Box::new(remote));
9460 let result = engine.start_gasp_sync().await.unwrap();
9461 let topic = &result.topics_synced["tm_test"];
9462 assert_eq!(topic.finalized_graphs, 4);
9463 assert_eq!(topic.cursor_moves[0].to, 20);
9464 }
9465
9466 struct ProofOnlyRootFetcher {
9467 proofs: HashMap<String, String>,
9468 requested: std::rc::Rc<Cell<u64>>,
9469 }
9470
9471 #[async_trait(?Send)]
9472 impl AncestorFetcher for ProofOnlyRootFetcher {
9473 async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
9474 assert!(
9475 self.requested.get() > 0,
9476 "root proof request must precede ancestry"
9477 );
9478 Err(GASPError::RemoteError("raw source unavailable".to_string()))
9479 }
9480 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
9481 self.requested.set(self.requested.get() + 1);
9482 self.proofs.get(txid).cloned()
9483 }
9484 }
9485
9486 #[tokio::test]
9487 async fn copy_sync_leaves_completion_courier_untouched() {
9488 let (mut engine, mut remote) = budget_page_engine(1);
9489 remote.node_cost_ms = 0;
9490 let txid = remote.outputs[0].txid.clone();
9491 let proof = std::rc::Rc::make_mut(&mut remote.nodes)
9492 .get_mut(&txid)
9493 .unwrap()
9494 .proof
9495 .take()
9496 .unwrap();
9497 let completion_asks = std::rc::Rc::new(Cell::new(0));
9498 let sync_asks = std::rc::Rc::new(Cell::new(0));
9499 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9500 proofs: HashMap::new(),
9501 requested: completion_asks.clone(),
9502 }));
9503 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9504 proofs: HashMap::from([(txid.clone(), proof.clone())]),
9505 requested: sync_asks.clone(),
9506 }));
9507 engine.set_gasp_remote_factory(Box::new(remote));
9508 let result = engine.start_gasp_sync().await.unwrap();
9509 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
9510 assert_eq!(result.topics_synced["tm_test"].roots_proven, 1);
9511 assert_eq!(result.topics_synced["tm_test"].roots_unproven, 0);
9512 let stored = engine
9513 .storage()
9514 .find_output(&txid, 0, Some("tm_test"), None, true)
9515 .await
9516 .unwrap()
9517 .unwrap();
9518 assert!(
9519 crate::beef_limits::own_proof(stored.beef.as_ref().unwrap(), &txid)
9520 .unwrap()
9521 .is_some()
9522 );
9523 assert_eq!(completion_asks.get(), 0);
9524 assert_eq!(sync_asks.get(), 1);
9525 let served = engine
9526 .provide_foreign_gasp_node(&format!("{txid}.0"), &txid, 0)
9527 .await
9528 .unwrap();
9529 assert_eq!(served.proof, Some(proof));
9530 }
9531
9532 #[tokio::test]
9533 async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
9534 for proven in [true, false] {
9535 let (mut engine, mut remote) = budget_page_engine(1);
9536 let txid = remote.outputs[0].txid.clone();
9537 let proof = remote.nodes[&txid].proof.clone().unwrap();
9538 std::rc::Rc::make_mut(&mut remote.nodes)
9539 .get_mut(&txid)
9540 .unwrap()
9541 .proof = None;
9542 let requested = std::rc::Rc::new(Cell::new(0));
9543 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9544 proofs: if proven {
9545 HashMap::from([(txid.clone(), proof)])
9546 } else {
9547 HashMap::new()
9548 },
9549 requested: requested.clone(),
9550 }));
9551 virtual_peer_budget(&mut engine, &remote);
9552 engine.set_gasp_remote_factory(Box::new(remote));
9553 let result = engine.start_gasp_sync().await.unwrap();
9554 assert!(requested.get() > 0);
9555 assert_eq!(
9556 result.topics_synced["tm_test"].finalized_graphs,
9557 u64::from(proven)
9558 );
9559 assert_eq!(
9560 engine
9561 .storage
9562 .find_output(&txid, 0, Some("tm_test"), None, true)
9563 .await
9564 .unwrap()
9565 .is_some(),
9566 proven
9567 );
9568 if !proven {
9569 assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
9570 }
9571 }
9572 }
9573
9574 #[tokio::test]
9575 async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
9576 let (mut engine, mut remote) = budget_page_engine(1);
9577 let txid = remote.outputs[0].txid.clone();
9578 let proof = remote.nodes[&txid].proof.clone().unwrap();
9579 std::rc::Rc::make_mut(&mut remote.nodes)
9580 .get_mut(&txid)
9581 .unwrap()
9582 .proof = None;
9583 let requested = std::rc::Rc::new(Cell::new(0));
9584 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9585 proofs: HashMap::new(),
9586 requested: requested.clone(),
9587 }));
9588 virtual_peer_budget(&mut engine, &remote);
9589 engine.set_graph_budget(
9590 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9591 5,
9592 5000,
9593 );
9594 engine.set_gasp_remote_factory(Box::new(remote));
9595 let held = engine.start_gasp_sync().await.unwrap();
9596 assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
9597 assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
9598 assert_eq!(held.topics_synced["tm_test"].cursor_moves[0].to, 10);
9599 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9600 proofs: HashMap::from([(txid.clone(), proof)]),
9601 requested,
9602 }));
9603 engine
9604 .storage()
9605 .update_last_interaction("https://budget.example", "tm_test", 20)
9606 .await
9607 .unwrap();
9608 let landed = engine.start_gasp_sync().await.unwrap();
9609 assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
9610 assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
9611 assert!(landed.topics_synced["tm_test"].cursor_moves.is_empty());
9612 assert!(engine
9613 .storage
9614 .find_output(&txid, 0, Some("tm_test"), None, true)
9615 .await
9616 .unwrap()
9617 .is_some());
9618 }
9619
9620 #[tokio::test]
9621 async fn proofs_deadline_stalls_become_durable_after_three_runs() {
9622 let (mut engine, remote) = budget_page_engine(1);
9623 let elapsed = remote.elapsed.clone();
9624 let root = format!("{}.0", remote.outputs[0].txid);
9625 engine.set_graph_budget(
9626 std::rc::Rc::new(move |ms| {
9627 let until = elapsed.get() + ms;
9628 let elapsed = elapsed.clone();
9629 Box::pin(std::future::poll_fn(move |_| {
9630 if elapsed.get() >= until {
9631 std::task::Poll::Ready(())
9632 } else {
9633 std::task::Poll::Pending
9634 }
9635 }))
9636 }),
9637 10,
9638 1_000,
9639 );
9640 engine.set_gasp_remote_factory(Box::new(remote));
9641 for round in 1..=crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9642 let result = engine.start_gasp_sync().await.unwrap();
9643 let topic = &result.topics_synced["tm_test"];
9644 if round < crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9645 assert!(topic.cursor_moves.is_empty());
9646 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
9647 } else {
9648 assert_eq!(topic.cursor_moves[0].to, 10);
9649 assert_eq!(topic.deferred_graphs, 1);
9650 }
9651 }
9652 let held = engine
9653 .storage()
9654 .get_deferred_graph("https://budget.example", "tm_test", &root)
9655 .await
9656 .unwrap()
9657 .unwrap();
9658 assert!(held.cursor_passable);
9659 assert!(held.nodes.is_empty());
9660 }
9661
9662 #[tokio::test]
9668 async fn cursor_018_the_graph_in_flight_at_the_peer_deadline_is_held_and_passed() {
9669 let (mut engine, remote) = budget_page_engine(6);
9670 let slow = remote.outputs[2].txid.clone();
9671 let root = format!("{slow}.0");
9672 *remote.slow_once.borrow_mut() = Some((slow.clone(), 60_000));
9673 virtual_peer_budget(&mut engine, &remote);
9674 engine.set_graph_budget(
9675 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9676 100,
9677 60_000,
9678 );
9679 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9680 let result = engine.start_gasp_sync().await.unwrap();
9681 let topic = &result.topics_synced["tm_test"];
9682 assert_eq!(topic.finalized_graphs, 2);
9683 assert_eq!(topic.deadline_dropped_graphs, 1);
9684 assert_eq!(topic.deferred_graphs, 1);
9685 assert_eq!(topic.cursor_moves.len(), 1);
9686 assert_eq!(topic.cursor_moves[0].to, 30, "past the held graph");
9687 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
9688 let held = engine
9689 .storage()
9690 .get_deferred_graph("https://budget.example", "tm_test", &root)
9691 .await
9692 .unwrap()
9693 .unwrap();
9694 assert!(held.cursor_passable);
9695 assert_eq!(held.reason, "peer_deadline");
9696 assert!(held.nodes.is_empty());
9697 assert_eq!(held.score, 30);
9698
9699 let result = engine.start_gasp_sync().await.unwrap();
9700 let topic = &result.topics_synced["tm_test"];
9701 assert_eq!(topic.resumed_graphs, 1);
9702 assert_eq!(topic.converged_graphs, 1);
9703 assert_eq!(topic.finalized_graphs, 4, "the held graph and 40, 50, 60");
9704 assert_eq!(topic.cursor_moves[0].to, 60);
9705 assert!(engine
9706 .storage()
9707 .get_deferred_graph("https://budget.example", "tm_test", &root)
9708 .await
9709 .unwrap()
9710 .is_none());
9711 for output in remote.outputs.iter() {
9712 assert!(engine
9713 .storage
9714 .find_output(&output.txid, 0, Some("tm_test"), None, true)
9715 .await
9716 .unwrap()
9717 .is_some());
9718 }
9719 }
9720
9721 struct ExpiredRoots(MockTopicManager);
9723
9724 #[async_trait(?Send)]
9725 impl TopicManagerTrait for ExpiredRoots {
9726 async fn identify_admissible_outputs(
9727 &self,
9728 tx: &Transaction,
9729 previous_coins: &[u8],
9730 off_chain_values: Option<&[u8]>,
9731 mode: SubmitMode,
9732 context: &TopicAdmittanceContext,
9733 ) -> Result<AdmittanceInstructions, TopicManagerError> {
9734 self.0
9735 .identify_admissible_outputs(tx, previous_coins, off_chain_values, mode, context)
9736 .await
9737 }
9738 async fn get_documentation(&self) -> String {
9739 self.0.get_documentation().await
9740 }
9741 async fn get_metadata(&self) -> ServiceMetadata {
9742 self.0.get_metadata().await
9743 }
9744 fn sync_expiry(&self, _: &bsv_rs::script::LockingScript) -> Option<u64> {
9745 Some(1)
9746 }
9747 }
9748
9749 fn dead_page_engine(count: u32) -> (Engine, BudgetPageRemote, std::rc::Rc<MemoryStorage>) {
9752 let (mut engine, mut remote) = budget_page_engine(count);
9753 let gap = remote.outputs[0].txid.clone();
9754 std::rc::Rc::make_mut(&mut remote.nodes).remove(&gap);
9755 engine.managers.insert(
9756 "tm_test".to_string(),
9757 Box::new(ExpiredRoots(MockTopicManager::admitting(vec![0]))),
9758 );
9759 let store = std::rc::Rc::new(MemoryStorage::new());
9760 engine.storage = Box::new(store.clone());
9761 engine.set_graph_budget(
9762 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9763 100,
9764 60_000,
9765 );
9766 (engine, remote, store)
9767 }
9768
9769 #[tokio::test]
9773 async fn cursor_018_a_sync_that_only_refuses_the_dead_is_a_yield() {
9774 let (mut engine, mut remote, store) = dead_page_engine(3);
9775 remote.node_cost_ms = 0;
9776 virtual_peer_budget(&mut engine, &remote);
9777 engine.set_gasp_remote_factory(Box::new(remote));
9778 let result = engine.start_gasp_sync().await.unwrap();
9779 let topic = &result.topics_synced["tm_test"];
9780 assert_eq!(topic.expired_refused, 2);
9781 assert_eq!(topic.finalized_graphs, 0);
9782 assert!(topic.cursor_moves.is_empty());
9783 let host = crate::gasp::peer_origin("https://budget.example");
9784 assert_eq!(store.peer_yieldless_syncs(&host, "tm_test"), 0);
9785 let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9786 assert_eq!(health.consecutive_failures, 0);
9787 }
9788
9789 #[tokio::test]
9795 async fn cursor_018_deadline_drops_in_a_row_do_not_quarantine_a_serving_peer() {
9796 let (mut engine, remote, store) = dead_page_engine(200);
9797 virtual_peer_budget(&mut engine, &remote);
9798 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9799 let host = crate::gasp::peer_origin("https://budget.example");
9800 let runs = crate::gasp::PEER_QUARANTINE_THRESHOLD + 1;
9801 for run in 1..=runs {
9802 let asked = remote.asked.borrow().len();
9803 let result = engine.start_gasp_sync().await.unwrap();
9804 let topic = &result.topics_synced["tm_test"];
9805 assert!(remote.asked.borrow().len() > asked, "run {run}: attempted");
9806 assert_eq!(topic.deadline_dropped_graphs, 1, "run {run}");
9807 assert!(topic.expired_refused > 0, "run {run}: {topic:?}");
9808 if run < u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9812 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
9813 } else if run == u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9814 assert_eq!(topic.unanchorable_graphs, 1, "run {run}: {topic:?}");
9815 assert!(!topic.cursor_moves.is_empty(), "run {run}: passed");
9816 }
9817 let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9818 assert_eq!(health.consecutive_failures, 0, "run {run}");
9819 }
9820 let asked = remote.asked.borrow();
9824 let mut dead: Vec<&String> = asked
9825 .iter()
9826 .filter(|t| **t != remote.outputs[0].txid)
9827 .collect();
9828 let fetched = dead.len();
9829 dead.sort();
9830 dead.dedup();
9831 assert_eq!(
9832 fetched - dead.len(),
9833 (runs - 1) as usize,
9834 "one per resumed cut"
9835 );
9836 assert_eq!(store.expired_outpoints("tm_test").len(), dead.len() - 1);
9837 }
9838
9839 struct GatedCourier {
9842 txid: String,
9843 raw_tx: String,
9844 proof: String,
9845 serve: std::rc::Rc<Cell<bool>>,
9846 asked: std::rc::Rc<Cell<u64>>,
9847 }
9848
9849 #[async_trait(?Send)]
9850 impl AncestorFetcher for GatedCourier {
9851 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
9852 self.asked.set(self.asked.get() + 1);
9853 if self.serve.get() && txid == self.txid {
9854 Ok(FetchedAncestor {
9855 raw_tx: self.raw_tx.clone(),
9856 proof: Some(self.proof.clone()),
9857 })
9858 } else {
9859 Err(GASPError::RemoteError(format!(
9860 "no rung served the raw of {txid}"
9861 )))
9862 }
9863 }
9864 async fn verified_proof_for(&self, _: &str) -> Option<String> {
9865 None
9866 }
9867 }
9868
9869 #[tokio::test]
9873 async fn load_0112_definitive_absence_strikes_a_resumed_obligation() {
9874 let mut fixture = obligation_fixture(1, 1, 0).await;
9875 fixture
9876 .engine
9877 .set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
9878 calls: std::rc::Rc::new(Cell::new(0)),
9879 bounded: false,
9880 }));
9881 let result = fixture.engine.start_gasp_sync().await.unwrap();
9882 let topic = &result.topics_synced["tm_test"];
9883 assert_eq!((topic.unanchorable_graphs, topic.finalized_graphs), (1, 1));
9884 let record = fixture
9885 .store
9886 .get_deferred_graph("https://budget.example", "tm_test", &fixture.obligations[0])
9887 .await
9888 .unwrap()
9889 .unwrap();
9890 assert_eq!(
9891 (record.reason.as_str(), record.faults),
9892 ("unanchorable:not_found", 1)
9893 );
9894 }
9895
9896 #[tokio::test]
9897 async fn load_0112_root_time_is_clamped_and_named() {
9898 let (mut engine, mut remote) = budget_page_engine(1);
9899 remote.node_cost_ms = 16_000;
9900 let elapsed = remote.elapsed.clone();
9901 let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
9902 let clock = elapsed.clone();
9903 let due = clock.get() + ms;
9904 Box::pin(std::future::poll_fn(move |_| {
9905 if clock.get() >= due {
9906 std::task::Poll::Ready(())
9907 } else {
9908 std::task::Poll::Pending
9909 }
9910 }))
9911 });
9912 engine.set_peer_sync_budget(sleep.clone(), 30_000);
9913 engine.set_graph_budget(sleep, 100, 60_000);
9914 engine.set_gasp_remote_factory(Box::new(remote));
9915 let result = engine.start_gasp_sync().await.unwrap();
9916 let topic = &result.topics_synced["tm_test"];
9917 assert_eq!(topic.finalized_graphs, 0);
9918 assert_eq!(topic.root_bounds[0].reason, "time");
9919 assert!(!topic.cursor_held_by.is_empty() || topic.deferred_graphs > 0);
9920 }
9921
9922 #[tokio::test]
9923 async fn load_0112_storage_counts_are_attempts_at_the_boundary() {
9924 let engine = make_engine(vec![0]);
9925 let meter = crate::meter::LoadMeter::default();
9926 meter
9927 .scope(async {
9928 assert_eq!(
9929 engine
9930 .storage
9931 .get_last_interaction("peer", "topic")
9932 .await
9933 .unwrap(),
9934 0
9935 );
9936 engine
9937 .storage
9938 .update_last_interaction("peer", "topic", 12)
9939 .await
9940 .unwrap();
9941 assert_eq!(
9942 engine
9943 .storage
9944 .get_last_interaction("peer", "topic")
9945 .await
9946 .unwrap(),
9947 12
9948 );
9949 })
9950 .await;
9951 assert_eq!(
9952 (
9953 meter.snapshot().storage_reads,
9954 meter.snapshot().storage_writes
9955 ),
9956 (2, 1)
9957 );
9958 engine
9959 .storage
9960 .get_last_interaction("peer", "topic")
9961 .await
9962 .unwrap();
9963 assert_eq!(meter.snapshot().storage_reads, 2);
9964 }
9965
9966 struct LoadCourier {
9967 calls: std::rc::Rc<Cell<u64>>,
9968 bounded: bool,
9969 }
9970 #[async_trait(?Send)]
9971 impl crate::gasp::AncestorFetcher for LoadCourier {
9972 async fn fetch_ancestor(
9973 &self,
9974 txid: &str,
9975 ) -> Result<crate::gasp::FetchedAncestor, crate::gasp::GASPError> {
9976 self.calls.set(self.calls.get() + 1);
9977 if self.bounded {
9978 for _ in 0..13 {
9979 crate::meter::courier_request()
9980 .map_err(|e| crate::gasp::GASPError::Other(e.into()))?;
9981 }
9982 Err(crate::gasp::GASPError::RemoteError(
9983 "still missing a proof".into(),
9984 ))
9985 } else {
9986 for _ in 0..3 {
9987 crate::meter::courier_request().unwrap();
9988 }
9989 Err(crate::gasp::GASPError::DefinitiveNotFound(txid.into()))
9990 }
9991 }
9992 async fn verified_proof_for(&self, _: &str) -> Option<String> {
9993 None
9994 }
9995 }
9996
9997 #[tokio::test]
9998 async fn load_0112_definitive_absence_is_shared_across_peers_and_topics() {
9999 let UnanchorableFixture {
10000 mut engine,
10001 remote,
10002 root,
10003 ..
10004 } = unanchorable_fixture();
10005 let calls = std::rc::Rc::new(Cell::new(0));
10006 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10007 calls: calls.clone(),
10008 bounded: false,
10009 }));
10010 engine.managers.insert(
10011 "tm_other".into(),
10012 Box::new(MockTopicManager::admitting(vec![0])),
10013 );
10014 for topic in ["tm_test", "tm_other"] {
10015 engine.config.sync_configuration.insert(
10016 topic.into(),
10017 SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
10018 );
10019 }
10020 let result = engine.start_gasp_sync().await.unwrap();
10021 assert_eq!(calls.get(), 1, "one ladder per missing ancestor per run");
10022 assert_eq!(result.cost.courier_not_found_memo_hits, 3);
10023 assert_eq!(result.cost.courier_rungs, 3);
10024 for (topic, res) in &result.topics_synced {
10025 assert_eq!(res.unanchorable_graphs, 2, "{res:?}");
10026 assert_eq!(
10027 res.cost.courier_rungs + res.cost.peer_requests,
10028 res.cost.calls_out
10029 );
10030 for host in ["https://a.example", "https://b.example"] {
10031 let mut record = engine
10032 .storage
10033 .get_deferred_graph(host, topic, &root)
10034 .await
10035 .unwrap()
10036 .unwrap();
10037 assert_eq!(record.reason, "unanchorable:not_found");
10038 assert_eq!(record.faults, 1);
10039 assert!(record.cursor_passable);
10040 record.retry_at = 0;
10041 engine.storage.put_deferred_graph(&record).await.unwrap();
10042 }
10043 }
10044 assert!(!remote.asked.borrow().is_empty());
10045 engine.start_gasp_sync().await.unwrap();
10046 assert_eq!(calls.get(), 2, "another run can retry absence");
10047 }
10048
10049 #[tokio::test]
10050 async fn load_0112_courier_bound_defers_the_root_and_names_it() {
10051 let UnanchorableFixture {
10052 mut engine, root, ..
10053 } = unanchorable_fixture();
10054 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10055 calls: std::rc::Rc::new(Cell::new(0)),
10056 bounded: true,
10057 }));
10058 let result = engine.start_gasp_sync().await.unwrap();
10059 let record = engine
10060 .storage
10061 .get_deferred_graph("https://budget.example", "tm_test", &root)
10062 .await
10063 .unwrap()
10064 .expect("bounded root must be retained");
10065 assert_eq!(record.reason, "courier_rungs");
10066 assert!(!record.nodes.is_empty());
10067 assert_eq!(result.cost.courier_rungs, 12);
10068 assert_eq!(
10069 result.topics_synced["tm_test"].root_bounds[0].reason,
10070 "courier_rungs"
10071 );
10072 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 2);
10073 }
10074
10075 #[tokio::test]
10076 async fn load_0112_refused_save_does_not_restart_a_bounded_root() {
10077 let UnanchorableFixture {
10078 mut engine,
10079 store,
10080 root,
10081 ..
10082 } = unanchorable_fixture();
10083 store.set_deferred_graph_ceiling(Some(0));
10084 let calls = std::rc::Rc::new(Cell::new(0));
10085 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10086 calls: calls.clone(),
10087 bounded: true,
10088 }));
10089 let result = engine.start_gasp_sync().await.unwrap();
10090 let topic = &result.topics_synced["tm_test"];
10091 assert_eq!(calls.get(), 1, "no retry after a hard root cut");
10092 assert_eq!(topic.root_bounds.len(), 1);
10093 assert_eq!(result.cost.courier_rungs, 12);
10094 assert!(topic.cursor_held_by.contains(&root));
10095 assert_eq!(
10096 topic.strikes_refused, 0,
10097 "a budget cut is not a fault strike"
10098 );
10099 assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10100 assert_eq!(topic.finalized_graphs, 2);
10101 }
10102
10103 #[tokio::test]
10104 async fn load_0112_refused_save_does_not_extend_root_time() {
10105 let (mut engine, mut remote) = budget_page_engine(1);
10106 remote.node_cost_ms = 16_000;
10107 let elapsed = remote.elapsed.clone();
10108 let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10109 let clock = elapsed.clone();
10110 let due = clock.get() + ms;
10111 Box::pin(std::future::poll_fn(move |_| {
10112 if clock.get() >= due {
10113 std::task::Poll::Ready(())
10114 } else {
10115 std::task::Poll::Pending
10116 }
10117 }))
10118 });
10119 let store = std::rc::Rc::new(MemoryStorage::new());
10120 store.set_deferred_graph_ceiling(Some(0));
10121 engine.storage = Box::new(store);
10122 engine.set_peer_sync_budget(sleep.clone(), 30_000);
10123 engine.set_graph_budget(sleep, 100, 60_000);
10124 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10125 let result = engine.start_gasp_sync().await.unwrap();
10126 let topic = &result.topics_synced["tm_test"];
10127 assert_eq!(
10128 remote.asked.borrow().len(),
10129 1,
10130 "no request after the time cut"
10131 );
10132 assert_eq!(topic.root_bounds.len(), 1);
10133 assert_eq!(topic.root_bounds[0].reason, "time");
10134 assert_eq!(topic.finalized_graphs, 0);
10135 assert!(!topic.cursor_held_by.is_empty());
10136 }
10137
10138 #[tokio::test]
10139 async fn load_0112_call_limit_fallback_keeps_the_root_deadline() {
10140 let UnanchorableFixture {
10141 mut engine,
10142 mut remote,
10143 store,
10144 root,
10145 serve,
10146 ..
10147 } = unanchorable_fixture();
10148 store.set_deferred_graph_ceiling(Some(0));
10149 serve.set(true);
10150 remote.node_cost_ms = 8_000;
10151 struct DelayedCourier {
10152 inner: std::rc::Rc<dyn AncestorFetcher>,
10153 clock: std::rc::Rc<Cell<u64>>,
10154 }
10155 #[async_trait(?Send)]
10156 impl AncestorFetcher for DelayedCourier {
10157 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10158 self.clock.set(self.clock.get() + 8_000);
10159 tokio::task::yield_now().await;
10160 self.inner.fetch_ancestor(txid).await
10161 }
10162 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
10163 self.inner.verified_proof_for(txid).await
10164 }
10165 }
10166 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(DelayedCourier {
10167 inner: engine.gasp_ancestor_fetcher.as_ref().unwrap().clone(),
10168 clock: remote.elapsed.clone(),
10169 }));
10170 let elapsed = remote.elapsed.clone();
10171 let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10172 let clock = elapsed.clone();
10173 let due = clock.get() + ms;
10174 Box::pin(std::future::poll_fn(move |_| {
10175 if clock.get() >= due {
10176 std::task::Poll::Ready(())
10177 } else {
10178 std::task::Poll::Pending
10179 }
10180 }))
10181 });
10182 engine.set_peer_sync_budget(sleep.clone(), 30_000);
10183 engine.set_graph_budget(sleep, 1, 60_000);
10184 engine.set_gasp_remote_factory(Box::new(remote));
10185 let result = engine.start_gasp_sync().await.unwrap();
10186 let topic = &result.topics_synced["tm_test"];
10187 assert!(
10188 topic
10189 .root_bounds
10190 .iter()
10191 .any(|b| b.outpoint == root && b.reason == "time"),
10192 "{topic:?}"
10193 );
10194 assert!(topic.cursor_held_by.contains(&root));
10195 assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10196 }
10197
10198 #[tokio::test]
10199 async fn load_0112_cost_matches_the_remote_call_log() {
10200 let (mut engine, remote) = budget_page_engine(2);
10201 remote.elapsed.set(0);
10202 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10203 let result = engine.start_gasp_sync().await.unwrap();
10204 let actual_calls = (remote.requests.borrow().len() + remote.asked.borrow().len()) as u64;
10205 assert_eq!(result.cost.peer_requests, actual_calls);
10206 assert_eq!(result.cost.calls_out, actual_calls);
10207 assert!(result.cost.storage_reads > 0);
10208 assert!(result.cost.storage_writes > 0);
10209 assert_eq!(
10210 result.cost.storage_reads,
10211 result.topics_synced["tm_test"].cost.storage_reads
10212 );
10213 assert_eq!(
10214 result.cost.storage_writes,
10215 result.topics_synced["tm_test"].cost.storage_writes
10216 );
10217 }
10218
10219 struct UnanchorableFixture {
10220 engine: Engine,
10221 remote: BudgetPageRemote,
10222 store: std::rc::Rc<MemoryStorage>,
10223 root: String,
10224 serve: std::rc::Rc<Cell<bool>>,
10225 asked: std::rc::Rc<Cell<u64>>,
10226 }
10227
10228 fn unanchorable_fixture() -> UnanchorableFixture {
10229 let (mut engine, mut remote) = budget_page_engine(3);
10230 remote.node_cost_ms = 0;
10231 let parent = child_of(&format!("{:064x}", 0xab), 0);
10232 let child = child_of(&parent.id(), 0);
10233 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
10234 for n in 1..=3u32 {
10235 tracker.add_root(n, child_of(&format!("{n:064x}"), 0).id());
10236 }
10237 tracker.add_root(900, parent.id());
10238 engine.chain_tracker = Some(Box::new(tracker));
10239 let replaced = std::mem::replace(
10240 &mut std::rc::Rc::make_mut(&mut remote.outputs)[0].txid,
10241 child.id(),
10242 );
10243 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10244 nodes.remove(&replaced);
10245 nodes.insert(
10246 child.id(),
10247 crate::types::GASPNode {
10248 graph_id: String::new(),
10249 raw_tx: child.to_hex(),
10250 output_index: 0,
10251 proof: None,
10252 tx_metadata: None,
10253 output_metadata: None,
10254 inputs: None,
10255 },
10256 );
10257 let serve = std::rc::Rc::new(Cell::new(false));
10258 let asked = std::rc::Rc::new(Cell::new(0));
10259 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(GatedCourier {
10260 txid: parent.id(),
10261 raw_tx: parent.to_hex(),
10262 proof: single_leaf_bump_hex(&parent.id(), 900),
10263 serve: serve.clone(),
10264 asked: asked.clone(),
10265 }));
10266 let store = std::rc::Rc::new(MemoryStorage::new());
10267 engine.storage = Box::new(store.clone());
10268 virtual_peer_budget(&mut engine, &remote);
10269 engine.set_graph_budget(
10270 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10271 100,
10272 60_000,
10273 );
10274 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10275 UnanchorableFixture {
10276 engine,
10277 remote,
10278 store,
10279 root: format!("{}.0", child.id()),
10280 serve,
10281 asked,
10282 }
10283 }
10284
10285 #[tokio::test]
10294 async fn unanchorable_019_a_root_faulted_three_runs_is_recorded_passed_and_admitted_later() {
10295 let UnanchorableFixture {
10296 engine,
10297 remote,
10298 store,
10299 root,
10300 serve,
10301 asked,
10302 } = unanchorable_fixture();
10303 let host = "https://budget.example";
10304 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10305 for run in 1..=runs {
10306 let result = engine.start_gasp_sync().await.unwrap();
10307 let topic = &result.topics_synced["tm_test"];
10308 assert_eq!(topic.faulted_roots.len(), 1, "run {run}: {topic:?}");
10309 let named = &topic.faulted_roots[0];
10310 assert_eq!(
10311 (named.peer.as_str(), named.outpoint.as_str()),
10312 (host, root.as_str())
10313 );
10314 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10315 assert_eq!(named.faults, run, "run {run}");
10316 assert!(named.error.contains("no rung served"), "{named:?}");
10317 let record = engine
10318 .storage()
10319 .get_deferred_graph(host, "tm_test", &root)
10320 .await
10321 .unwrap()
10322 .unwrap();
10323 assert_eq!(record.faults, run);
10324 assert!(record.nodes.is_empty(), "the root alone, asked again");
10325 if run < runs {
10326 assert!(!named.unanchorable);
10327 assert_eq!(topic.unanchorable_graphs, 0);
10328 assert_eq!(record.reason, "retry:fault:courier");
10329 assert!(!record.cursor_passable);
10330 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10331 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10332 } else {
10333 assert!(named.unanchorable);
10334 assert_eq!(topic.unanchorable_graphs, 1);
10335 assert_eq!(record.reason, "unanchorable:courier");
10336 assert!(record.cursor_passable);
10337 assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
10338 assert_eq!(topic.cursor_moves.len(), 1);
10339 assert_eq!(
10340 topic.cursor_moves[0].to, 30,
10341 "the page's last finished score"
10342 );
10343 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10344 }
10345 if run == 1 {
10346 assert_eq!(topic.finalized_graphs, 2, "20 and 30");
10347 }
10348 }
10349
10350 let (courier_asks, node_asks) = (asked.get(), remote.asked.borrow().len());
10353 let streak = store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test");
10354 let result = engine.start_gasp_sync().await.unwrap();
10355 let topic = &result.topics_synced["tm_test"];
10356 assert!(topic.faulted_roots.is_empty(), "{topic:?}");
10357 assert!(topic.cursor_held_by.is_empty());
10358 assert_eq!(topic.unanchorable_graphs, 0);
10359 assert_eq!(asked.get(), courier_asks);
10360 assert_eq!(remote.asked.borrow().len(), node_asks);
10361 assert_eq!(
10362 store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test"),
10363 streak
10364 );
10365
10366 let mut record = engine
10369 .storage()
10370 .get_deferred_graph(host, "tm_test", &root)
10371 .await
10372 .unwrap()
10373 .unwrap();
10374 record.retry_at = 1;
10375 engine.storage().put_deferred_graph(&record).await.unwrap();
10376 serve.set(true);
10377 let result = engine.start_gasp_sync().await.unwrap();
10378 let topic = &result.topics_synced["tm_test"];
10379 assert_eq!(topic.finalized_graphs, 1, "{topic:?}");
10380 assert_eq!(topic.unanchorable_cleared, 1);
10381 assert!(topic.faulted_roots.is_empty());
10382 assert!(engine
10383 .storage()
10384 .get_deferred_graph(host, "tm_test", &root)
10385 .await
10386 .unwrap()
10387 .is_none());
10388 let (txid, _) = crate::gasp::parse_outpoint(&root).unwrap();
10389 assert!(engine
10390 .storage
10391 .find_output(&txid, 0, Some("tm_test"), None, true)
10392 .await
10393 .unwrap()
10394 .is_some());
10395 }
10396
10397 #[tokio::test]
10402 async fn unanchorable_019_only_the_lowest_faulted_root_is_recorded_and_all_are_named() {
10403 let (mut engine, mut remote) = budget_page_engine(4);
10404 remote.node_cost_ms = 0;
10405 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10406 nodes.remove(&remote.outputs[1].txid);
10407 nodes.remove(&remote.outputs[3].txid);
10408 let store = std::rc::Rc::new(MemoryStorage::new());
10409 engine.storage = Box::new(store.clone());
10410 virtual_peer_budget(&mut engine, &remote);
10411 engine.set_graph_budget(
10412 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10413 100,
10414 60_000,
10415 );
10416 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10417 let result = engine.start_gasp_sync().await.unwrap();
10418 let topic = &result.topics_synced["tm_test"];
10419 let named: Vec<(&str, &str, u32)> = topic
10420 .faulted_roots
10421 .iter()
10422 .map(|f| (f.outpoint.as_str(), f.class.as_str(), f.faults))
10423 .collect();
10424 let low = format!("{}.0", remote.outputs[1].txid);
10425 let high = format!("{}.0", remote.outputs[3].txid);
10426 assert_eq!(
10427 named,
10428 vec![
10429 (low.as_str(), crate::gasp::fault_class::NOT_SERVED, 1),
10430 (high.as_str(), crate::gasp::fault_class::NOT_SERVED, 0),
10431 ]
10432 );
10433 assert_eq!(topic.cursor_moves[0].to, 10, "below the lower gap");
10434 let host = "https://budget.example";
10435 assert!(store
10436 .get_deferred_graph(host, "tm_test", &high)
10437 .await
10438 .unwrap()
10439 .is_none());
10440 assert_eq!(
10441 store
10442 .get_deferred_graph(host, "tm_test", &low)
10443 .await
10444 .unwrap()
10445 .unwrap()
10446 .reason,
10447 "retry:fault:not_served"
10448 );
10449 }
10450
10451 fn bare_record(n: u32, score: u64, reason: &str, passable: bool) -> crate::gasp::DeferredGraph {
10454 let outpoint = format!("{:064x}.0", 0xdead_0000 + n);
10455 crate::gasp::DeferredGraph {
10456 peer: "https://budget.example".into(),
10457 topic: "tm_test".into(),
10458 outpoint: outpoint.clone(),
10459 score,
10460 nodes: Vec::new(),
10461 pending: vec![crate::gasp::PendingInput {
10462 outpoint: outpoint.clone(),
10463 graph_id: outpoint,
10464 metadata: true,
10465 spent_by: None,
10466 parent_proven: false,
10467 }],
10468 calls: 0,
10469 passes: 0,
10470 reason: reason.into(),
10471 configured: true,
10472 idle_faults: 0,
10473 cursor_passable: passable,
10474 faults: 0,
10475 retry_at: 0,
10476 idle_resumes: 0,
10477 unanchorable_retries: 0,
10478 expiry: None,
10479 }
10480 }
10481
10482 async fn hold_obligations(engine: &Engine, count: usize) -> Vec<String> {
10486 let mut roots = Vec::new();
10487 for n in 0..count {
10488 let record = bare_record(n as u32, 1 + n as u64, "retry", true);
10489 roots.push(record.outpoint.clone());
10490 engine.storage().put_deferred_graph(&record).await.unwrap();
10491 }
10492 roots
10493 }
10494
10495 #[tokio::test]
10503 async fn room_0110_a_full_store_still_records_the_cursors_holder_and_passes_it() {
10504 let UnanchorableFixture {
10505 engine,
10506 store,
10507 root,
10508 ..
10509 } = unanchorable_fixture();
10510 let host = "https://budget.example";
10511 let obligations =
10512 hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10513 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10514 for run in 1..=runs {
10515 let result = engine.start_gasp_sync().await.unwrap();
10516 let topic = &result.topics_synced["tm_test"];
10517 assert_eq!(topic.strikes_refused, 0, "run {run}: {topic:?}");
10518 let named = topic
10519 .faulted_roots
10520 .iter()
10521 .find(|f| f.outpoint == root)
10522 .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
10523 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10524 assert_eq!(named.faults, run, "run {run}: the strike was written");
10525 assert!(named.refused.is_empty(), "{named:?}");
10526 if run < runs {
10527 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10528 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10529 } else {
10530 assert!(named.unanchorable);
10531 assert_eq!(topic.unanchorable_graphs, 1);
10532 assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10533 assert_eq!(topic.cursor_moves[0].to, 30, "passed");
10534 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10535 }
10536 }
10537 let held: Vec<String> = store
10539 .deferred_graphs()
10540 .into_iter()
10541 .map(|r| r.outpoint)
10542 .collect();
10543 assert_eq!(held.len(), obligations.len() + 1, "{held:?}");
10544 assert!(obligations.iter().all(|o| held.contains(o)));
10545 let record = store
10546 .get_deferred_graph(host, "tm_test", &root)
10547 .await
10548 .unwrap()
10549 .unwrap();
10550 assert_eq!(record.reason, "unanchorable:courier");
10551 }
10552
10553 #[tokio::test]
10560 async fn room_0110_a_refused_strike_is_counted_and_named() {
10561 let UnanchorableFixture {
10562 engine,
10563 store,
10564 root,
10565 ..
10566 } = unanchorable_fixture();
10567 let later = (current_timestamp_ms() / 1000.0) as u64 + 3600;
10570 for n in 0..crate::gasp::FAULT_RECORDS_PER_PEER_TOPIC {
10571 let mut record = bare_record(n as u32, 1, "unanchorable:courier", true);
10572 record.faults = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10573 record.retry_at = later;
10574 engine.storage().put_deferred_graph(&record).await.unwrap();
10575 }
10576 let refused_as = |result: &super::GASPSyncResult, why: &str| {
10577 let topic = &result.topics_synced["tm_test"];
10578 assert_eq!(topic.strikes_refused, 1, "{topic:?}");
10579 let named = topic
10580 .faulted_roots
10581 .iter()
10582 .find(|f| f.outpoint == root)
10583 .unwrap_or_else(|| panic!("{topic:?}"));
10584 assert_eq!(named.refused, why, "{named:?}");
10585 assert_eq!(named.faults, 0, "no record holds the count");
10586 assert!(topic.cursor_moves.is_empty(), "the gap holds");
10587 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10588 };
10589 let result = engine.start_gasp_sync().await.unwrap();
10590 refused_as(&result, crate::gasp::strike_refused::NO_ROOM);
10591 assert!(store
10592 .get_deferred_graph("https://budget.example", "tm_test", &root)
10593 .await
10594 .unwrap()
10595 .is_none());
10596
10597 let gone = bare_record(0, 1, "", false).outpoint;
10599 engine
10600 .storage()
10601 .delete_deferred_graph("https://budget.example", "tm_test", &gone)
10602 .await
10603 .unwrap();
10604 store.set_deferred_graph_ceiling(Some(store.deferred_graphs().len()));
10605 let result = engine.start_gasp_sync().await.unwrap();
10606 refused_as(&result, crate::gasp::strike_refused::NO_ROOM);
10607
10608 store.set_deferred_graph_ceiling(None);
10610 store.set_deferred_graph_put_fault(true);
10611 let result = engine.start_gasp_sync().await.unwrap();
10612 refused_as(&result, crate::gasp::strike_refused::STORE_FAULT);
10613
10614 store.set_deferred_graph_put_fault(false);
10616 let result = engine.start_gasp_sync().await.unwrap();
10617 let topic = &result.topics_synced["tm_test"];
10618 assert_eq!(topic.strikes_refused, 0, "{topic:?}");
10619 assert!(store
10620 .get_deferred_graph("https://budget.example", "tm_test", &root)
10621 .await
10622 .unwrap()
10623 .is_some());
10624 }
10625
10626 #[tokio::test]
10633 async fn room_0110_idle_obligations_back_off_and_the_page_is_walked() {
10634 let (mut engine, mut remote) = budget_page_engine(3);
10635 remote.node_cost_ms = 2000;
10636 let store = std::rc::Rc::new(MemoryStorage::new());
10637 engine.storage = Box::new(store.clone());
10638 virtual_peer_budget(&mut engine, &remote);
10639 engine.set_graph_budget(
10640 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10641 100,
10642 60_000,
10643 );
10644 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10645 let obligations =
10646 hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10647 let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10648 for run in 1..=idle {
10652 let result = engine.start_gasp_sync().await.unwrap();
10653 let topic = &result.topics_synced["tm_test"];
10654 assert_eq!(topic.finalized_graphs, 0, "run {run}: {topic:?}");
10655 assert!(topic.cursor_moves.is_empty(), "run {run}");
10656 assert_eq!(topic.resumed_graphs, 15, "run {run}");
10657 assert_eq!(topic.waiting_graphs, 0, "run {run}");
10658 let expect = if run == idle { 15 } else { 0 };
10659 assert_eq!(topic.backed_off_graphs, expect, "run {run}: {topic:?}");
10660 }
10661 let result = engine.start_gasp_sync().await.unwrap();
10663 let topic = &result.topics_synced["tm_test"];
10664 assert_eq!(topic.waiting_graphs, 15, "{topic:?}");
10665 assert_eq!(topic.finalized_graphs, 3, "the page's fresh roots");
10666 assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10667 assert_eq!(topic.cursor_moves[0].to, 30);
10668 let mut backed_off = 15;
10670 for _ in 0..idle {
10671 let result = engine.start_gasp_sync().await.unwrap();
10672 backed_off += result.topics_synced["tm_test"].backed_off_graphs;
10673 }
10674 assert_eq!(backed_off, obligations.len() as u64);
10675 let asked = remote.asked.borrow().len();
10676 let result = engine.start_gasp_sync().await.unwrap();
10677 let topic = &result.topics_synced["tm_test"];
10678 assert_eq!(topic.waiting_graphs, obligations.len() as u64, "{topic:?}");
10679 assert_eq!(topic.resumed_graphs, 0);
10680 assert!(topic.dropped_graphs.is_empty(), "{topic:?}");
10681 assert_eq!(remote.asked.borrow().len(), asked, "no request");
10682 let now = (current_timestamp_ms() / 1000.0) as u64;
10684 let held = store.deferred_graphs();
10685 assert_eq!(held.len(), obligations.len());
10686 for record in &held {
10687 assert!(obligations.contains(&record.outpoint));
10688 assert!(record.cursor_passable);
10689 assert!(record.idle_resumes >= idle, "{record:?}");
10690 assert!(record.retry_at > now, "{record:?}");
10691 }
10692 }
10693
10694 #[tokio::test]
10702 async fn room_0110_a_waiting_record_whose_root_expired_is_swept() {
10703 let (mut engine, remote) = budget_page_engine(1);
10704 let store = std::rc::Rc::new(MemoryStorage::new());
10705 engine.storage = Box::new(store.clone());
10706 engine.set_graph_budget(
10707 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10708 100,
10709 60_000,
10710 );
10711 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10712 let now = (current_timestamp_ms() / 1000.0) as u64;
10713 let mut waiting = bare_record(0, 1, "retry", true);
10714 waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10715 waiting.retry_at = now + 3600;
10716 let dead = bare_record(1, 2, "retry", true);
10717 let mut dead_waiting = dead.clone();
10718 dead_waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10719 dead_waiting.retry_at = now + 3600;
10720 for record in [&waiting, &dead_waiting] {
10721 engine.storage().put_deferred_graph(record).await.unwrap();
10722 }
10723 engine
10724 .storage()
10725 .remember_expired_outpoint("tm_test", &dead.outpoint, 1)
10726 .await
10727 .unwrap();
10728 let result = engine.start_gasp_sync().await.unwrap();
10729 let topic = &result.topics_synced["tm_test"];
10730 assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
10731 assert_eq!(
10732 topic
10733 .dropped_graphs
10734 .iter()
10735 .map(|d| (d.outpoint.as_str(), d.reason.as_str()))
10736 .collect::<Vec<_>>(),
10737 vec![(dead.outpoint.as_str(), "refused")]
10738 );
10739 let held: Vec<String> = store
10740 .deferred_graphs()
10741 .into_iter()
10742 .map(|r| r.outpoint)
10743 .collect();
10744 assert_eq!(held, vec![waiting.outpoint.clone()]);
10745 let asked = remote.asked.borrow();
10746 assert!(!asked.iter().any(|t| dead.outpoint.starts_with(t.as_str())));
10747 assert!(!asked
10748 .iter()
10749 .any(|t| waiting.outpoint.starts_with(t.as_str())));
10750 }
10751
10752 #[tokio::test]
10755 async fn room_0110_a_resume_that_appends_a_node_resets_the_count() {
10756 let UnanchorableFixture {
10757 mut engine,
10758 remote,
10759 store,
10760 root,
10761 ..
10762 } = unanchorable_fixture();
10763 engine.set_graph_budget_limbs(u64::MAX, 1);
10765 engine.set_gasp_remote_factory(Box::new(remote));
10766 let mut record = bare_record(0, 10, "retry", true);
10767 record.outpoint.clone_from(&root);
10768 record.pending[0].outpoint.clone_from(&root);
10769 record.pending[0].graph_id.clone_from(&root);
10770 record.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF - 1;
10771 engine.storage().put_deferred_graph(&record).await.unwrap();
10772 let result = engine.start_gasp_sync().await.unwrap();
10773 let topic = &result.topics_synced["tm_test"];
10774 assert_eq!(topic.resumed_graphs, 1, "{topic:?}");
10775 assert_eq!(topic.backed_off_graphs, 0);
10776 let held = store
10777 .get_deferred_graph("https://budget.example", "tm_test", &root)
10778 .await
10779 .unwrap()
10780 .unwrap();
10781 assert_eq!(held.nodes.len(), 1, "{held:?}");
10782 assert_eq!(held.reason, "nodes");
10783 assert_eq!(held.idle_resumes, 0);
10784 assert_eq!(held.retry_at, 0);
10785 }
10786
10787 struct ExhaustedCourier {
10791 asked: std::rc::Rc<Cell<u64>>,
10792 transient: std::rc::Rc<Cell<bool>>,
10794 }
10795
10796 #[async_trait(?Send)]
10797 impl AncestorFetcher for ExhaustedCourier {
10798 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10799 self.asked.set(self.asked.get() + 1);
10800 if self.transient.get() {
10801 return Err(GASPError::RemoteError(format!(
10802 "rung rate limited for {txid}"
10803 )));
10804 }
10805 Err(GASPError::NodeNotFound(format!(
10806 "no raw tx for {txid} (bitails + woc + bananablocks exhausted)"
10807 )))
10808 }
10809 async fn verified_proof_for(&self, _: &str) -> Option<String> {
10810 None
10811 }
10812 }
10813
10814 struct ObligationFixture {
10820 engine: Engine,
10821 remote: BudgetPageRemote,
10822 store: std::rc::Rc<MemoryStorage>,
10823 obligations: Vec<String>,
10824 courier: std::rc::Rc<Cell<u64>>,
10825 transient: std::rc::Rc<Cell<bool>>,
10826 }
10827
10828 async fn obligation_fixture(page: u32, count: u32, node_cost_ms: u64) -> ObligationFixture {
10829 let (mut engine, mut remote) = budget_page_engine(page);
10830 remote.node_cost_ms = node_cost_ms;
10831 let mut obligations = Vec::new();
10832 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10833 let mut records = Vec::new();
10834 for n in 0..count {
10835 let parent = child_of(&format!("{:064x}", 0xbeef_0000 + n), 0);
10836 let child = child_of(&parent.id(), 0);
10837 nodes.insert(
10838 child.id(),
10839 crate::types::GASPNode {
10840 graph_id: String::new(),
10841 raw_tx: child.to_hex(),
10842 output_index: 0,
10843 proof: None,
10844 tx_metadata: None,
10845 output_metadata: None,
10846 inputs: None,
10847 },
10848 );
10849 let mut record = bare_record(n, 1 + u64::from(n), "retry", true);
10850 let outpoint = format!("{}.0", child.id());
10851 record.outpoint.clone_from(&outpoint);
10852 record.pending[0].outpoint.clone_from(&outpoint);
10853 record.pending[0].graph_id.clone_from(&outpoint);
10854 obligations.push(outpoint);
10855 records.push(record);
10856 }
10857 let courier = std::rc::Rc::new(Cell::new(0));
10858 let transient = std::rc::Rc::new(Cell::new(false));
10859 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
10860 asked: courier.clone(),
10861 transient: transient.clone(),
10862 }));
10863 let store = std::rc::Rc::new(MemoryStorage::new());
10864 engine.storage = Box::new(store.clone());
10865 virtual_peer_budget(&mut engine, &remote);
10866 engine.set_graph_budget(
10867 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10868 100,
10869 60_000,
10870 );
10871 engine.set_gasp_remote_factory(Box::new(remote.clone()));
10872 for record in &records {
10873 let json = serde_json::to_value(record).unwrap();
10875 assert!(json.get("idleResumes").is_none() && json.get("faults").is_none());
10876 engine.storage().put_deferred_graph(record).await.unwrap();
10877 }
10878 ObligationFixture {
10879 engine,
10880 remote,
10881 store,
10882 obligations,
10883 courier,
10884 transient,
10885 }
10886 }
10887
10888 #[tokio::test]
10897 async fn obligations_0111_a_faulted_resume_is_struck_and_the_page_is_walked() {
10898 let ObligationFixture {
10899 engine,
10900 remote,
10901 store,
10902 obligations,
10903 courier,
10904 ..
10905 } = obligation_fixture(3, 1, 0).await;
10906 let host = "https://budget.example";
10907 let held = &obligations[0];
10908 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10909 for run in 1..=runs {
10910 let result = engine.start_gasp_sync().await.unwrap();
10911 let topic = &result.topics_synced["tm_test"];
10912 let named = topic
10913 .faulted_roots
10914 .iter()
10915 .find(|f| &f.outpoint == held)
10916 .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
10917 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10918 assert!(named.error.contains("exhausted"), "{named:?}");
10919 assert_eq!(named.faults, run, "run {run}: struck on its own slot");
10920 assert!(named.refused.is_empty(), "{named:?}");
10921 assert_eq!(topic.strikes_refused, 0);
10922 assert!(topic.cursor_held_by.is_empty(), "run {run}: {topic:?}");
10923 if run == 1 {
10924 assert_eq!(topic.finalized_graphs, 3, "the page, the same run");
10925 assert_eq!(topic.cursor_moves[0].to, 30);
10926 }
10927 let record = store
10928 .get_deferred_graph(host, "tm_test", held)
10929 .await
10930 .unwrap()
10931 .unwrap();
10932 assert_eq!(record.faults, run, "{record:?}");
10933 assert!(record.cursor_passable, "run {run}: never holds the cursor");
10934 assert!(record.nodes.is_empty());
10935 if run < runs {
10936 assert!(!named.unanchorable);
10937 assert_eq!(record.reason, "retry:fault:courier");
10938 assert_eq!(topic.unanchorable_graphs, 0);
10939 } else {
10940 assert!(named.unanchorable);
10941 assert_eq!(record.reason, "unanchorable:courier");
10942 assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
10943 assert_eq!(topic.unanchorable_graphs, 1, "{topic:?}");
10944 }
10945 }
10946 let (asked, couriered) = (remote.asked.borrow().len(), courier.get());
10949 let result = engine.start_gasp_sync().await.unwrap();
10950 let topic = &result.topics_synced["tm_test"];
10951 assert!(topic.faulted_roots.is_empty(), "{topic:?}");
10952 assert_eq!(topic.resumed_graphs, 0);
10953 assert_eq!(remote.asked.borrow().len(), asked);
10954 assert_eq!(courier.get(), couriered);
10955 assert_eq!(store.deferred_graphs().len(), 1);
10956 }
10957
10958 #[tokio::test]
10970 async fn obligations_0111_a_resume_that_appends_nothing_counts_on_every_ending() {
10971 let host = "https://budget.example";
10972 let fixture = obligation_fixture(1, 1, 0).await;
10975 let later = (current_timestamp_ms() / 1000.0) as u64 + 3600;
10976 for n in 0..crate::gasp::FAULT_RECORDS_PER_PEER_TOPIC {
10977 let mut record = bare_record(100 + n as u32, 50, "unanchorable:courier", true);
10978 record.faults = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10979 record.retry_at = later;
10980 fixture
10981 .engine
10982 .storage()
10983 .put_deferred_graph(&record)
10984 .await
10985 .unwrap();
10986 }
10987 let held = &fixture.obligations[0];
10988 let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10989 for run in 1..=idle {
10990 let result = fixture.engine.start_gasp_sync().await.unwrap();
10991 let topic = &result.topics_synced["tm_test"];
10992 let record = fixture
10993 .store
10994 .get_deferred_graph(host, "tm_test", held)
10995 .await
10996 .unwrap()
10997 .unwrap();
10998 assert_eq!(record.idle_resumes, run, "run {run}: {record:?} {topic:?}");
10999 assert_eq!(record.reason, "retry", "the drop stands");
11000 assert!(record.cursor_passable && record.nodes.is_empty());
11001 assert_eq!(topic.idle_counted, 1, "run {run}: {topic:?}");
11002 let named = topic
11003 .faulted_roots
11004 .iter()
11005 .find(|f| &f.outpoint == held)
11006 .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11007 assert_eq!(named.refused, crate::gasp::strike_refused::NO_ROOM);
11008 assert_eq!(named.faults, 0);
11009 assert_eq!(topic.strikes_refused, 1);
11010 assert_eq!(topic.backed_off_graphs, u64::from(run == idle));
11011 }
11012 let result = fixture.engine.start_gasp_sync().await.unwrap();
11013 let topic = &result.topics_synced["tm_test"];
11014 assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
11015 assert_eq!(topic.idle_counted, 0);
11016
11017 let mut fixture = obligation_fixture(1, 1, 0).await;
11019 let gone = fixture.obligations[0].clone();
11020 std::rc::Rc::make_mut(&mut fixture.remote.nodes).remove(gone.strip_suffix(".0").unwrap());
11021 fixture
11022 .engine
11023 .set_gasp_remote_factory(Box::new(fixture.remote.clone()));
11024 let result = fixture.engine.start_gasp_sync().await.unwrap();
11025 let topic = &result.topics_synced["tm_test"];
11026 assert_eq!(topic.idle_counted, 1, "{topic:?}");
11027 assert_eq!(
11028 topic
11029 .dropped_graphs
11030 .iter()
11031 .map(|d| d.reason.as_str())
11032 .collect::<Vec<_>>(),
11033 vec!["not_held"]
11034 );
11035 let record = fixture
11036 .store
11037 .get_deferred_graph(host, "tm_test", &gone)
11038 .await
11039 .unwrap()
11040 .unwrap();
11041 assert_eq!((record.idle_resumes, record.faults), (1, 0), "{record:?}");
11042
11043 let fixture = obligation_fixture(1, 1, 0).await;
11047 fixture.transient.set(true);
11048 let kept = &fixture.obligations[0];
11049 let result = fixture.engine.start_gasp_sync().await.unwrap();
11050 assert_eq!(result.topics_synced["tm_test"].idle_counted, 0, "progress");
11051 let result = fixture.engine.start_gasp_sync().await.unwrap();
11052 let topic = &result.topics_synced["tm_test"];
11053 assert_eq!(topic.idle_counted, 1, "{topic:?}");
11054 let record = fixture
11055 .store
11056 .get_deferred_graph(host, "tm_test", kept)
11057 .await
11058 .unwrap()
11059 .unwrap();
11060 assert_eq!(record.reason, "fault");
11061 assert_eq!(
11062 (record.nodes.len(), record.idle_resumes),
11063 (1, 1),
11064 "{record:?}"
11065 );
11066 }
11067
11068 #[tokio::test]
11079 async fn obligations_0111_fourteen_obligations_leave_the_page_its_share() {
11080 let ObligationFixture {
11081 mut engine,
11082 remote,
11083 store,
11084 obligations,
11085 ..
11086 } = obligation_fixture(12, 14, 1500).await;
11087 let elapsed = remote.elapsed.clone();
11088 engine.set_graph_budget(
11089 std::rc::Rc::new(move |ms| {
11090 let elapsed = elapsed.clone();
11091 let until = elapsed.get() + ms;
11092 Box::pin(std::future::poll_fn(move |_| {
11093 if elapsed.get() >= until {
11094 std::task::Poll::Ready(())
11095 } else {
11096 std::task::Poll::Pending
11097 }
11098 }))
11099 }),
11100 100,
11101 15_000,
11102 );
11103 assert_eq!(crate::gasp::resume_share_ms(30_000, 15_000), 9_900);
11104 let result = engine.start_gasp_sync().await.unwrap();
11105 let topic = &result.topics_synced["tm_test"];
11106 assert!(topic.errors.is_empty(), "inside the slice: {topic:?}");
11107 assert_eq!(topic.finalized_graphs, 12, "the page's share: {topic:?}");
11108 assert_eq!(topic.cursor_moves[0].to, 120);
11109 assert_eq!(topic.resumed_graphs, 7, "{topic:?}");
11113 assert_eq!(topic.held_back_graphs, 7, "{topic:?}");
11114 assert_eq!(topic.faulted_roots.len(), 6, "{topic:?}");
11115 assert_eq!((topic.stalled_graphs, topic.idle_counted), (1, 1));
11116 assert!(topic.cursor_held_by.is_empty());
11117 let host = "https://budget.example";
11118 let mut reasons = std::collections::BTreeMap::new();
11119 for outpoint in &obligations {
11120 let record = store
11121 .get_deferred_graph(host, "tm_test", outpoint)
11122 .await
11123 .unwrap()
11124 .unwrap();
11125 assert!(record.cursor_passable, "{record:?}");
11126 if record.reason == "retry" {
11127 assert_eq!((record.idle_resumes, record.retry_at), (0, 0));
11129 assert_eq!((record.faults, record.passes), (0, 0));
11130 }
11131 *reasons.entry(record.reason).or_insert(0) += 1;
11132 }
11133 assert_eq!(
11134 reasons,
11135 std::collections::BTreeMap::from([
11136 ("retry".to_string(), 7),
11137 ("retry:fault:courier".to_string(), 6),
11138 ("time".to_string(), 1),
11139 ])
11140 );
11141 }
11142
11143 #[tokio::test]
11144 async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
11145 let (mut engine, mut remote) = budget_page_engine(1);
11146 let txid = remote.outputs[0].txid.clone();
11147 std::rc::Rc::make_mut(&mut remote.nodes)
11148 .get_mut(&txid)
11149 .unwrap()
11150 .proof = None;
11151 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
11152 proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
11153 requested: std::rc::Rc::default(),
11154 }));
11155 virtual_peer_budget(&mut engine, &remote);
11156 engine.set_gasp_remote_factory(Box::new(remote));
11157 let result = engine.start_gasp_sync().await.unwrap();
11158 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
11159 assert!(engine
11160 .storage
11161 .find_output(&txid, 0, Some("tm_test"), None, true)
11162 .await
11163 .unwrap()
11164 .is_none());
11165 }
11166
11167 impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
11168 fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11169 Box::new(MockSyncRemote {
11170 utxos: vec![
11171 crate::types::GASPOutput {
11172 txid: "remote_tx1".to_string(),
11173 output_index: 0,
11174 score: 100.0,
11175 },
11176 crate::types::GASPOutput {
11177 txid: "remote_tx2".to_string(),
11178 output_index: 0,
11179 score: 200.0,
11180 },
11181 ],
11182 })
11183 }
11184 }
11185
11186 #[tokio::test]
11187 async fn test_start_gasp_sync_with_factory_runs_sync() {
11188 let mut sync_config: SyncConfiguration = HashMap::new();
11189 sync_config.insert(
11190 "tm_test".to_string(),
11191 SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
11192 );
11193
11194 let mut engine = Engine::new(
11195 HashMap::new(),
11196 HashMap::new(),
11197 Box::new(MemoryStorage::new()),
11198 None,
11199 EngineConfig {
11200 sync_configuration: sync_config,
11201 ..Default::default()
11202 },
11203 );
11204
11205 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11206
11207 let result = engine.start_gasp_sync().await.unwrap();
11208 assert_eq!(result.topics_synced.len(), 1);
11209
11210 let topic_result = &result.topics_synced["tm_test"];
11211 assert_eq!(topic_result.peers.len(), 1);
11212 assert!(
11213 topic_result.errors.is_empty(),
11214 "Sync should succeed with mock remote"
11215 );
11216
11217 let last = engine
11219 .storage()
11220 .get_last_interaction("https://peer1.example.com", "tm_test")
11221 .await
11222 .unwrap();
11223 assert_eq!(
11226 last, 0,
11227 "cursor must remain at entry when no output completed"
11228 );
11229 }
11230
11231 #[tokio::test]
11232 async fn test_start_gasp_sync_with_factory_handles_error() {
11233 struct FailingFactory;
11234
11235 impl crate::gasp::GASPRemoteFactory for FailingFactory {
11236 fn create_remote(
11237 &self,
11238 _peer_url: &str,
11239 _topic: &str,
11240 ) -> Box<dyn crate::gasp::GASPRemote> {
11241 struct FailingRemote;
11242 #[async_trait(?Send)]
11243 impl crate::gasp::GASPRemote for FailingRemote {
11244 async fn get_initial_response(
11245 &self,
11246 _: &crate::types::GASPInitialRequest,
11247 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
11248 {
11249 Err(crate::gasp::GASPError::RemoteError(
11250 "connection refused".into(),
11251 ))
11252 }
11253 async fn get_initial_reply(
11254 &self,
11255 _: &crate::types::GASPInitialResponse,
11256 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
11257 {
11258 unreachable!()
11259 }
11260 async fn request_node(
11261 &self,
11262 _: &str,
11263 _: &str,
11264 _: u32,
11265 _: bool,
11266 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
11267 {
11268 unreachable!()
11269 }
11270 async fn submit_node(
11271 &self,
11272 _: &crate::types::GASPNode,
11273 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
11274 {
11275 unreachable!()
11276 }
11277 }
11278 Box::new(FailingRemote)
11279 }
11280 }
11281
11282 let mut sync_config: SyncConfiguration = HashMap::new();
11283 sync_config.insert(
11284 "tm_test".to_string(),
11285 SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
11286 );
11287
11288 let mut engine = Engine::new(
11289 HashMap::new(),
11290 HashMap::new(),
11291 Box::new(MemoryStorage::new()),
11292 None,
11293 EngineConfig {
11294 sync_configuration: sync_config,
11295 ..Default::default()
11296 },
11297 );
11298
11299 engine.set_gasp_remote_factory(Box::new(FailingFactory));
11300
11301 let result = engine.start_gasp_sync().await.unwrap();
11302 assert_eq!(result.topics_synced.len(), 1);
11303
11304 let topic_result = &result.topics_synced["tm_test"];
11305 assert_eq!(
11306 topic_result.errors.len(),
11307 1,
11308 "Should have one error for the failing peer"
11309 );
11310 assert!(topic_result.errors[0].contains("connection refused"));
11311 }
11312
11313 #[tokio::test]
11314 async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
11315 let mut sync_config: SyncConfiguration = HashMap::new();
11316 sync_config.insert(
11317 "tm_test".to_string(),
11318 SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
11319 );
11320
11321 let engine = Engine::new(
11323 HashMap::new(),
11324 HashMap::new(),
11325 Box::new(MemoryStorage::new()),
11326 None,
11327 EngineConfig {
11328 sync_configuration: sync_config,
11329 ..Default::default()
11330 },
11331 );
11332
11333 let result = engine.start_gasp_sync().await.unwrap();
11334 assert_eq!(result.topics_synced.len(), 1);
11335
11336 let topic_result = &result.topics_synced["tm_test"];
11337 assert_eq!(topic_result.peers.len(), 1);
11338 assert!(topic_result.errors.is_empty());
11339 }
11340
11341 #[tokio::test]
11342 async fn test_start_gasp_sync_with_factory_multiple_peers() {
11343 let mut sync_config: SyncConfiguration = HashMap::new();
11344 sync_config.insert(
11345 "tm_test".to_string(),
11346 SyncTarget::Peers(vec![
11347 "https://peer1.example.com".to_string(),
11348 "https://peer2.example.com".to_string(),
11349 ]),
11350 );
11351
11352 let mut engine = Engine::new(
11353 HashMap::new(),
11354 HashMap::new(),
11355 Box::new(MemoryStorage::new()),
11356 None,
11357 EngineConfig {
11358 sync_configuration: sync_config,
11359 ..Default::default()
11360 },
11361 );
11362
11363 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11364
11365 let result = engine.start_gasp_sync().await.unwrap();
11366 let topic_result = &result.topics_synced["tm_test"];
11367 assert_eq!(topic_result.peers.len(), 2);
11368 assert!(topic_result.errors.is_empty());
11369
11370 let last1 = engine
11372 .storage()
11373 .get_last_interaction("https://peer1.example.com", "tm_test")
11374 .await
11375 .unwrap();
11376 let last2 = engine
11377 .storage()
11378 .get_last_interaction("https://peer2.example.com", "tm_test")
11379 .await
11380 .unwrap();
11381 assert_eq!(last1, 0);
11382 assert_eq!(last2, 0);
11383 }
11384
11385 struct HangingRemote;
11391
11392 #[async_trait(?Send)]
11393 impl crate::gasp::GASPRemote for HangingRemote {
11394 async fn get_initial_response(
11395 &self,
11396 _: &crate::types::GASPInitialRequest,
11397 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
11398 std::future::pending::<()>().await;
11399 unreachable!()
11400 }
11401 async fn get_initial_reply(
11402 &self,
11403 _: &crate::types::GASPInitialResponse,
11404 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
11405 unreachable!()
11406 }
11407 async fn request_node(
11408 &self,
11409 _: &str,
11410 _: &str,
11411 _: u32,
11412 _: bool,
11413 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
11414 unreachable!()
11415 }
11416 async fn submit_node(
11417 &self,
11418 _: &crate::types::GASPNode,
11419 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
11420 unreachable!()
11421 }
11422 }
11423
11424 struct SelectiveHangFactory {
11428 created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
11429 }
11430
11431 impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
11432 fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11433 self.created.borrow_mut().push(peer_url.to_string());
11434 if peer_url.contains("hang.example.com") {
11435 Box::new(HangingRemote)
11436 } else {
11437 Box::new(MockSyncRemote {
11438 utxos: vec![crate::types::GASPOutput {
11439 txid: "remote_tx1".to_string(),
11440 output_index: 0,
11441 score: 100.0,
11442 }],
11443 })
11444 }
11445 }
11446 }
11447
11448 fn instant_deadline() -> crate::engine::SleepFactory {
11453 std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
11454 }
11455
11456 fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
11457 let mut sync_config: SyncConfiguration = HashMap::new();
11458 sync_config.insert(
11459 "tm_test".to_string(),
11460 SyncTarget::Peers(vec![
11461 "https://hang.example.com".to_string(),
11462 "https://good.example.com".to_string(),
11463 ]),
11464 );
11465 Engine::new(
11466 HashMap::new(),
11467 HashMap::new(),
11468 Box::new(store),
11469 None,
11470 EngineConfig {
11471 sync_configuration: sync_config,
11472 ..Default::default()
11473 },
11474 )
11475 }
11476
11477 #[tokio::test]
11478 async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
11479 let store = std::rc::Rc::new(MemoryStorage::new());
11480 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11481 let mut engine = two_peer_engine(store.clone());
11482 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11483 created: created.clone(),
11484 }));
11485 engine.set_peer_sync_budget(instant_deadline(), 1);
11486
11487 let result = engine.start_gasp_sync().await.unwrap();
11488 let topic_result = &result.topics_synced["tm_test"];
11489
11490 assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
11493 assert!(
11494 topic_result.errors[0].contains("budget"),
11495 "{:?}",
11496 topic_result.errors
11497 );
11498 assert_eq!(
11499 created.borrow().as_slice(),
11500 ["https://hang.example.com", "https://good.example.com"],
11501 "both peers attempted this tick"
11502 );
11503
11504 let hang_cursor = store
11507 .get_last_interaction("https://hang.example.com", "tm_test")
11508 .await
11509 .unwrap();
11510 assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
11511 let good_cursor = store
11512 .get_last_interaction("https://good.example.com", "tm_test")
11513 .await
11514 .unwrap();
11515 assert_eq!(good_cursor, 0, "no synthetic graph completed");
11516
11517 let hang_health = store
11519 .get_peer_sync_health("hang.example.com", "tm_test")
11520 .await
11521 .unwrap();
11522 assert_eq!(hang_health.consecutive_failures, 1);
11523 let good_health = store
11524 .get_peer_sync_health("good.example.com", "tm_test")
11525 .await
11526 .unwrap();
11527 assert_eq!(good_health.consecutive_failures, 0);
11528 }
11529
11530 #[tokio::test]
11531 async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
11532 let store = std::rc::Rc::new(MemoryStorage::new());
11533 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11534
11535 let mut sync_config: SyncConfiguration = HashMap::new();
11536 sync_config.insert(
11537 "tm_test".to_string(),
11538 SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
11539 );
11540 let mut engine = Engine::new(
11541 HashMap::new(),
11542 HashMap::new(),
11543 Box::new(store.clone()),
11544 None,
11545 EngineConfig {
11546 sync_configuration: sync_config,
11547 ..Default::default()
11548 },
11549 );
11550 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11551 created: created.clone(),
11552 }));
11553 engine.set_peer_sync_budget(instant_deadline(), 1);
11554
11555 for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
11559 engine.start_gasp_sync().await.unwrap();
11560 store.advance_clock(900);
11561 assert_eq!(
11562 created.borrow().len() as u64,
11563 i + 1,
11564 "peer attempted while below the threshold"
11565 );
11566 }
11567 let health = store
11568 .get_peer_sync_health("hang.example.com", "tm_test")
11569 .await
11570 .unwrap();
11571 assert_eq!(
11572 health.consecutive_failures,
11573 crate::gasp::PEER_QUARANTINE_THRESHOLD
11574 );
11575
11576 let result = engine.start_gasp_sync().await.unwrap();
11580 assert!(result.topics_synced["tm_test"].errors.is_empty());
11581 assert_eq!(
11582 created.borrow().len() as u64,
11583 crate::gasp::PEER_QUARANTINE_THRESHOLD,
11584 "quarantined peer must be skipped"
11585 );
11586
11587 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11589 engine.start_gasp_sync().await.unwrap();
11590 assert_eq!(
11591 created.borrow().len() as u64,
11592 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
11593 "re-probe attempted after the window"
11594 );
11595 let health = store
11597 .get_peer_sync_health("hang.example.com", "tm_test")
11598 .await
11599 .unwrap();
11600 assert_eq!(
11601 health.consecutive_failures,
11602 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
11603 );
11604 let result = engine.start_gasp_sync().await.unwrap();
11605 assert!(
11606 result.topics_synced["tm_test"].errors.is_empty(),
11607 "re-quarantined"
11608 );
11609
11610 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11614 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11616 let result = engine.start_gasp_sync().await.unwrap();
11617 assert!(result.topics_synced["tm_test"].errors.is_empty());
11618 let health = store
11619 .get_peer_sync_health("hang.example.com", "tm_test")
11620 .await
11621 .unwrap();
11622 assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
11623 }
11624
11625 use crate::broadcaster::ArcBroadcaster;
11628
11629 struct MockArcBroadcaster {
11630 calls: Arc<Mutex<Vec<String>>>,
11631 should_fail: bool,
11632 }
11633
11634 impl MockArcBroadcaster {
11635 fn new() -> Self {
11636 Self {
11637 calls: Arc::new(Mutex::new(Vec::new())),
11638 should_fail: false,
11639 }
11640 }
11641
11642 fn failing() -> Self {
11643 Self {
11644 calls: Arc::new(Mutex::new(Vec::new())),
11645 should_fail: true,
11646 }
11647 }
11648 }
11649
11650 #[async_trait(?Send)]
11651 impl ArcBroadcaster for MockArcBroadcaster {
11652 async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
11653 self.calls.lock().unwrap().push(raw_tx_hex.to_string());
11654 if self.should_fail {
11655 Err("mock ARC failure".to_string())
11656 } else {
11657 Ok("mock_txid_from_arc".to_string())
11658 }
11659 }
11660 }
11661
11662 fn make_engine_with_arc(
11664 admit_indices: Vec<u32>,
11665 arc: MockArcBroadcaster,
11666 ) -> (Engine, Arc<Mutex<Vec<String>>>) {
11667 let arc_calls = arc.calls.clone();
11668
11669 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11670 managers.insert(
11671 "tm_test".to_string(),
11672 Box::new(MockTopicManager::admitting(admit_indices)),
11673 );
11674
11675 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
11676 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
11677
11678 let storage = Box::new(MemoryStorage::new());
11679
11680 let engine = Engine::with_all(
11681 managers,
11682 lookup_services,
11683 storage,
11684 None,
11685 None,
11686 Some(Box::new(arc)),
11687 None,
11688 EngineConfig::default(),
11689 );
11690
11691 (engine, arc_calls)
11692 }
11693
11694 #[tokio::test]
11697 async fn test_arc_broadcaster_called_on_current_tx() {
11698 let arc = MockArcBroadcaster::new();
11699 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11700
11701 let beef = test_tagged_beef(vec!["tm_test"]);
11702 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11703
11704 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11705
11706 let calls = arc_calls.lock().unwrap();
11707 assert_eq!(
11708 calls.len(),
11709 1,
11710 "ARC broadcaster should be called once for CurrentTx"
11711 );
11712 assert!(
11713 !calls[0].is_empty(),
11714 "ARC should receive non-empty raw tx hex"
11715 );
11716 }
11717
11718 #[tokio::test]
11719 async fn test_arc_broadcaster_not_called_on_historical_tx() {
11720 let arc = MockArcBroadcaster::new();
11721 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11722
11723 let beef = test_tagged_beef(vec!["tm_test"]);
11724 engine
11725 .submit(&beef, SubmitMode::HistoricalTx)
11726 .await
11727 .unwrap();
11728
11729 assert_eq!(
11730 arc_calls.lock().unwrap().len(),
11731 0,
11732 "ARC broadcaster should NOT be called for HistoricalTx"
11733 );
11734 }
11735
11736 #[tokio::test]
11737 async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
11738 let arc = MockArcBroadcaster::new();
11739 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11740
11741 let beef = test_tagged_beef(vec!["tm_test"]);
11742 engine
11743 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
11744 .await
11745 .unwrap();
11746
11747 assert_eq!(
11748 arc_calls.lock().unwrap().len(),
11749 0,
11750 "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
11751 );
11752 }
11753
11754 #[tokio::test]
11755 async fn test_arc_broadcast_failure_does_not_fail_submit() {
11756 let arc = MockArcBroadcaster::failing();
11757 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11758
11759 let beef = test_tagged_beef(vec!["tm_test"]);
11760 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11762
11763 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11764
11765 assert_eq!(
11767 arc_calls.lock().unwrap().len(),
11768 1,
11769 "ARC broadcaster should be attempted even if it will fail"
11770 );
11771 }
11772
11773 #[tokio::test]
11774 async fn test_arc_broadcaster_not_present_still_works() {
11775 let engine = make_engine(vec![0]);
11777
11778 let beef = test_tagged_beef(vec!["tm_test"]);
11779 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11780
11781 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11782 }
11783
11784 struct TrackingAdvertiser {
11788 created: Arc<Mutex<Vec<AdvertisementData>>>,
11789 existing_ship: Vec<Advertisement>,
11790 existing_slap: Vec<Advertisement>,
11791 tag_topics: Vec<String>,
11795 create_fails: bool,
11797 find_fails: bool,
11799 revoke_empty: bool,
11801 }
11802
11803 impl TrackingAdvertiser {
11804 fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
11805 let created = Arc::new(Mutex::new(Vec::new()));
11806 (
11807 Self {
11808 created: created.clone(),
11809 existing_ship: vec![],
11810 existing_slap: vec![],
11811 tag_topics: vec!["tm_test".to_string()],
11812 create_fails: false,
11813 find_fails: false,
11814 revoke_empty: false,
11815 },
11816 created,
11817 )
11818 }
11819 }
11820
11821 use crate::advertiser::{Advertiser, AdvertiserError};
11822
11823 #[async_trait(?Send)]
11824 impl Advertiser for TrackingAdvertiser {
11825 async fn create_advertisements(
11826 &self,
11827 ads: &[AdvertisementData],
11828 ) -> Result<TaggedBEEF, AdvertiserError> {
11829 if self.create_fails {
11830 return Err(AdvertiserError::CreationFailed(
11831 "mock create refused".into(),
11832 ));
11833 }
11834 self.created.lock().unwrap().extend(ads.iter().cloned());
11835 Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
11836 }
11837
11838 async fn find_all_advertisements(
11839 &self,
11840 protocol: Protocol,
11841 ) -> Result<Vec<Advertisement>, AdvertiserError> {
11842 if self.find_fails {
11843 return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
11844 }
11845 match protocol {
11846 Protocol::Ship => Ok(self.existing_ship.clone()),
11847 Protocol::Slap => Ok(self.existing_slap.clone()),
11848 }
11849 }
11850
11851 async fn revoke_advertisements(
11852 &self,
11853 _ads: &[Advertisement],
11854 ) -> Result<TaggedBEEF, AdvertiserError> {
11855 if self.revoke_empty {
11856 return Ok(TaggedBEEF::new(vec![], vec![]));
11857 }
11858 Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
11859 }
11860
11861 fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
11862 None
11863 }
11864 }
11865
11866 #[tokio::test]
11870 async fn test_sync_advertisements_url_validation() {
11871 let (adv, created) = TrackingAdvertiser::new();
11873
11874 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11875 managers.insert(
11876 "tm_test".to_string(),
11877 Box::new(MockTopicManager::admitting(vec![0])),
11878 );
11879
11880 let engine = Engine::new(
11881 managers,
11882 HashMap::new(),
11883 Box::new(MemoryStorage::new()),
11884 Some(Box::new(adv)),
11885 EngineConfig {
11886 hosting_url: Some(String::new()), ..Default::default()
11888 },
11889 );
11890
11891 engine.sync_advertisements().await.unwrap();
11892 assert!(
11893 created.lock().unwrap().is_empty(),
11894 "Empty hosting URL should skip advertisement creation"
11895 );
11896
11897 let (adv2, created2) = TrackingAdvertiser::new();
11899
11900 let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11901 managers2.insert(
11902 "tm_test".to_string(),
11903 Box::new(MockTopicManager::admitting(vec![0])),
11904 );
11905 managers2.insert(
11907 "tm_ship".to_string(),
11908 Box::new(MockTopicManager::admitting(vec![])),
11909 );
11910
11911 let engine2 = Engine::new(
11912 managers2,
11913 HashMap::new(),
11914 Box::new(MemoryStorage::new()),
11915 Some(Box::new(adv2)),
11916 EngineConfig {
11917 hosting_url: Some("https://valid.example.com".to_string()),
11918 suppress_default_sync_advertisements: true,
11919 ..Default::default()
11920 },
11921 );
11922
11923 engine2.sync_advertisements().await.unwrap();
11924 let ads = created2.lock().unwrap();
11925 assert!(
11927 ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
11928 "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
11929 );
11930 assert!(
11931 ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
11932 "tm_test should get a SHIP advertisement"
11933 );
11934 }
11935
11936 fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
11945 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11946 managers.insert(
11947 "tm_test".to_string(),
11948 Box::new(MockTopicManager::admitting(vec![0])),
11949 );
11950 Engine::new(
11951 managers,
11952 HashMap::new(),
11953 Box::new(MemoryStorage::new()),
11954 Some(Box::new(adv)),
11955 EngineConfig {
11956 hosting_url: Some("https://valid.example.com".to_string()),
11957 suppress_default_sync_advertisements: true,
11958 ..Default::default()
11959 },
11960 )
11961 }
11962
11963 #[tokio::test]
11964 async fn sync_report_counts_admitted_outputs_on_success() {
11965 let (adv, _) = TrackingAdvertiser::new();
11966 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
11967 assert!(report.ok(), "clean run must report ok: {report:?}");
11968 assert_eq!(report.to_create, 1, "tm_test was missing");
11969 assert_eq!(
11970 report.admitted.get("tm_test"),
11971 Some(&1),
11972 "the STEAK's admitted count must be surfaced: {report:?}"
11973 );
11974 }
11975
11976 #[tokio::test]
11977 async fn sync_report_surfaces_local_submit_failure() {
11978 let (mut adv, _) = TrackingAdvertiser::new();
11982 adv.tag_topics = vec!["tm_unregistered".to_string()];
11983 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
11984 assert!(!report.ok(), "a failed local submit must not report ok");
11985 assert_eq!(report.to_create, 1);
11986 let submit_error = report.submit_error.as_deref().unwrap_or_default();
11987 assert!(
11988 submit_error.contains("tm_unregistered"),
11989 "verbatim engine error expected, got: {submit_error}"
11990 );
11991 assert!(report.admitted.is_empty());
11992 }
11993
11994 #[tokio::test]
11995 async fn sync_report_surfaces_create_failure() {
11996 let (mut adv, _) = TrackingAdvertiser::new();
11997 adv.create_fails = true;
11998 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
11999 assert!(!report.ok());
12000 assert!(
12001 report
12002 .create_error
12003 .as_deref()
12004 .unwrap_or_default()
12005 .contains("mock create refused"),
12006 "{report:?}"
12007 );
12008 assert!(report.submit_error.is_none(), "create failed before submit");
12009 }
12010
12011 #[tokio::test]
12012 async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
12013 let (mut adv, created) = TrackingAdvertiser::new();
12017 adv.existing_ship = vec![Advertisement {
12018 protocol: Protocol::Ship,
12019 identity_key: "02aa".to_string(),
12020 domain: "https://valid.example.com".to_string(),
12021 topic_or_service: "tm_test".to_string(),
12022 beef: None,
12023 output_index: None,
12024 }];
12025 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12026 assert!(report.ok(), "{report:?}");
12027 assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
12028 assert!(created.lock().unwrap().is_empty());
12029 assert!(
12030 report.effective(),
12031 "converged-noop is the one zero-admit shape that IS success"
12032 );
12033 }
12034
12035 #[tokio::test]
12036 async fn sync_report_zero_admit_is_ok_but_not_effective() {
12037 let (adv, _) = TrackingAdvertiser::new();
12043 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12044 managers.insert(
12045 "tm_test".to_string(),
12046 Box::new(MockTopicManager::admitting(vec![])), );
12048 let engine = Engine::new(
12049 managers,
12050 HashMap::new(),
12051 Box::new(MemoryStorage::new()),
12052 Some(Box::new(adv)),
12053 EngineConfig {
12054 hosting_url: Some("https://valid.example.com".to_string()),
12055 suppress_default_sync_advertisements: true,
12056 ..Default::default()
12057 },
12058 );
12059 let report = engine.sync_advertisements().await.unwrap();
12060 assert!(report.ok(), "no ERROR occurred: {report:?}");
12061 assert_eq!(report.to_create, 1);
12062 assert_eq!(report.admitted_total(), 0, "{report:?}");
12063 assert!(
12064 !report.effective(),
12065 "zero admitted on a non-empty create must not read as success: {report:?}"
12066 );
12067 }
12068
12069 #[tokio::test]
12070 async fn sync_report_partial_admit_is_not_effective() {
12071 let (mut adv, _) = TrackingAdvertiser::new();
12077 adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
12078 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12079 managers.insert(
12080 "tm_test".to_string(),
12081 Box::new(MockTopicManager::admitting(vec![0])),
12082 );
12083 managers.insert(
12084 "tm_refuse".to_string(),
12085 Box::new(MockTopicManager::admitting(vec![])), );
12087 let engine = Engine::new(
12088 managers,
12089 HashMap::new(),
12090 Box::new(MemoryStorage::new()),
12091 Some(Box::new(adv)),
12092 EngineConfig {
12093 hosting_url: Some("https://valid.example.com".to_string()),
12094 suppress_default_sync_advertisements: true,
12095 ..Default::default()
12096 },
12097 );
12098 let report = engine.sync_advertisements().await.unwrap();
12099 assert!(report.ok(), "no ERROR occurred: {report:?}");
12100 assert_eq!(report.to_create, 2, "{report:?}");
12101 assert_eq!(report.admitted_total(), 1, "{report:?}");
12102 assert!(
12103 !report.effective(),
12104 "a partial admit must not read as success: {report:?}"
12105 );
12106 }
12107
12108 #[tokio::test]
12109 async fn sync_report_lookup_failure_refuses_creation() {
12110 let (mut adv, created) = TrackingAdvertiser::new();
12114 adv.find_fails = true;
12115 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12116 assert!(!report.ok(), "{report:?}");
12117 assert!(!report.effective());
12118 assert!(
12119 report
12120 .lookup_error
12121 .as_deref()
12122 .unwrap_or_default()
12123 .contains("mock lookup refused"),
12124 "{report:?}"
12125 );
12126 assert_eq!(report.to_create, 0, "creation refused, not attempted");
12127 assert!(
12128 created.lock().unwrap().is_empty(),
12129 "no create call on a blind read"
12130 );
12131 assert!(report.submit_error.is_none());
12132 }
12133
12134 #[tokio::test]
12135 async fn sync_report_counts_skipped_revocations() {
12136 let (mut adv, _) = TrackingAdvertiser::new();
12139 adv.revoke_empty = true;
12140 adv.existing_ship = vec![
12141 Advertisement {
12142 protocol: Protocol::Ship,
12143 identity_key: "02aa".to_string(),
12144 domain: "https://valid.example.com".to_string(),
12145 topic_or_service: "tm_test".to_string(),
12146 beef: None,
12147 output_index: None,
12148 },
12149 Advertisement {
12150 protocol: Protocol::Ship,
12151 identity_key: "02aa".to_string(),
12152 domain: "https://valid.example.com".to_string(),
12153 topic_or_service: "tm_stale".to_string(), beef: None,
12155 output_index: None,
12156 },
12157 ];
12158 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12159 assert!(report.ok(), "{report:?}");
12160 assert_eq!(report.to_revoke, 1, "tm_stale is stale");
12161 assert_eq!(
12162 report.revoke_skipped, 1,
12163 "the v1 no-op must be visible, not silent: {report:?}"
12164 );
12165 assert!(report.revoke_error.is_none());
12166 assert!(report.revoke_submit_error.is_none());
12167 }
12168
12169 use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
12172 use std::cell::Cell;
12173
12174 const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12176
12177 fn proofless_beef() -> (Vec<u8>, String) {
12179 use bsv_rs::transaction::{Beef, Transaction};
12180 let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
12181 let txid = tx.id();
12182 let mut beef = Beef::new();
12183 beef.merge_transaction(tx);
12184 (beef.to_binary(), txid)
12185 }
12186
12187 fn subject_not_last_beef() -> (Vec<u8>, String, String) {
12197 use bsv_rs::transaction::Beef;
12198 let parsed = Beef::from_binary(&test_beef()).unwrap();
12199 let parent = parsed.txs[0].tx().unwrap().clone();
12200 let child = parsed.txs[1].tx().unwrap().clone();
12201 let (parent_id, child_id) = (parent.id(), child.id());
12202 assert_ne!(parent_id, child_id);
12203
12204 let mut beef = Beef::new();
12210 beef.merge_transaction(child); beef.merge_transaction(parent);
12212 let mut w = bsv_rs::primitives::encoding::Writer::new();
12213 beef.to_writer(&mut w);
12214 let bytes = w.into_bytes();
12215
12216 let reread = Beef::from_binary(&bytes).unwrap();
12219 assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
12220 (bytes, child_id, parent_id)
12221 }
12222
12223 #[test]
12224 fn stitch_preserves_the_subject_when_it_is_not_last() {
12225 use bsv_rs::transaction::{Beef, MerklePath};
12226 let (stored, child_id, parent_id) = subject_not_last_beef();
12227 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
12228
12229 let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
12230 .expect("stitch must succeed on a beef that contains the txid");
12231
12232 let after = Beef::from_binary(&out).unwrap();
12233 assert!(
12234 after.find_txid(&child_id).is_some(),
12235 "the SUBJECT must survive its own proof-completion"
12236 );
12237 assert!(
12238 after.find_txid(&parent_id).is_some(),
12239 "the ancestry must survive too"
12240 );
12241 assert!(
12242 after.find_bump(&child_id).is_some(),
12243 "the stitched bump must prove the subject"
12244 );
12245 assert!(
12246 after.find_bump(&parent_id).is_none(),
12247 "the parent must NOT be claimed by the child's bump"
12248 );
12249 }
12250
12251 #[test]
12256 fn the_old_round_trip_demonstrably_dropped_the_subject() {
12257 use bsv_rs::transaction::{Beef, Transaction};
12258 let (stored, child_id, _parent_id) = subject_not_last_beef();
12259 let tx = Transaction::from_beef(&stored, None).unwrap();
12260 assert_ne!(
12261 tx.id(),
12262 child_id,
12263 "from_beef(None) picks txs.last(), not the subject — the trigger"
12264 );
12265 let rebuilt = tx.to_beef(true).unwrap();
12266 let reparsed = Beef::from_binary(&rebuilt).unwrap();
12267 assert!(
12268 reparsed.find_txid(&child_id).is_none(),
12269 "the rebuild loses the subject — exactly the #284 artifact"
12270 );
12271 }
12272
12273 #[test]
12279 fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
12280 use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
12281 let parent = Transaction::from_hex(RAW_PARENT).unwrap();
12283 let parent_id = parent.id();
12284 let subject = child_of(&parent_id, 0);
12285 let subject_id = subject.id();
12286 let orphan = MerklePath::new_unchecked(
12289 965_771,
12290 vec![vec![
12291 MerklePathLeaf::new_txid(0, subject_id.clone()),
12292 MerklePathLeaf::new(1, "aa".repeat(32)),
12293 ]],
12294 )
12295 .unwrap();
12296 let mut beef = Beef::new();
12297 let bi = beef.merge_bump(orphan.clone());
12298 beef.merge_raw_tx(parent.to_binary(), None);
12299 beef.merge_raw_tx(subject.to_binary(), Some(bi));
12300 let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
12301 let stored = beef.to_binary();
12302 assert_eq!(
12303 Beef::from_binary(&stored)
12304 .unwrap()
12305 .find_bump(&subject_id)
12306 .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
12307 Some(orphan_root.clone())
12308 );
12309 let canonical = MerklePath::new_unchecked(
12311 965_771,
12312 vec![vec![
12313 MerklePathLeaf::new_txid(0, subject_id.clone()),
12314 MerklePathLeaf::new(1, "bb".repeat(32)),
12315 ]],
12316 )
12317 .unwrap();
12318 let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
12319 assert_ne!(
12320 canonical_root, orphan_root,
12321 "the reorg shape: same height, new root"
12322 );
12323 let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
12324 let after = Beef::from_binary(&out).unwrap();
12325 let own = after
12327 .find_txid(&subject_id)
12328 .and_then(BeefTx::bump_index)
12329 .and_then(|bi| after.bumps.get(bi))
12330 .unwrap();
12331 assert_eq!(
12332 own.compute_root(Some(&subject_id)).unwrap(),
12333 canonical_root,
12334 "the subject re-anchored to the new bump"
12335 );
12336 assert_eq!(
12338 after.bumps.len(),
12339 1,
12340 "the unreferenced orphan bump was dropped"
12341 );
12342 assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
12344 }
12345
12346 #[test]
12347 fn stitch_refuses_a_beef_that_lacks_the_txid() {
12348 use bsv_rs::transaction::MerklePath;
12349 let (stored, _c, _p) = subject_not_last_beef();
12350 let foreign = "aa".repeat(32);
12351 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
12352 assert!(
12353 Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
12354 "no txid in the beef -> no write, never garbage"
12355 );
12356 }
12357
12358 const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12360
12361 fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
12363 let mut sx = String::from("0100000001");
12364 let mut prev = hex::decode(source_txid).unwrap();
12365 prev.reverse();
12366 sx.push_str(&hex::encode(prev));
12367 sx.push_str(&hex::encode(vout.to_le_bytes()));
12368 sx.push_str("00ffffffff01");
12369 sx.push_str(&hex::encode(1000u64.to_le_bytes()));
12370 sx.push_str("015100000000");
12371 bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
12372 }
12373
12374 fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
12376 use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
12377 let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
12378 MerklePath::new_unchecked(height, vec![vec![leaf]])
12379 .unwrap()
12380 .to_hex()
12381 }
12382
12383 struct StubFetcher {
12386 proof: Option<String>,
12387 calls: Cell<u32>,
12388 }
12389
12390 #[async_trait(?Send)]
12391 impl AncestorFetcher for StubFetcher {
12392 async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
12393 self.calls.set(self.calls.get() + 1);
12394 Ok(FetchedAncestor {
12395 raw_tx: PROOFLESS_RAW_TX.to_string(),
12396 proof: self.proof.clone(),
12397 })
12398 }
12399
12400 async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
12406 true
12407 }
12408 }
12409
12410 async fn engine_with_proofless_output(
12413 fetcher: Option<std::rc::Rc<StubFetcher>>,
12414 ) -> (Engine, String) {
12415 let (beef, txid) = proofless_beef();
12416 let storage = MemoryStorage::new();
12417 storage
12418 .insert_output(&Output {
12419 txid: txid.clone(),
12420 output_index: 0,
12421 output_script: vec![0x76],
12422 satoshis: 1_000_000_000,
12423 topic: "Hello".to_string(),
12424 spent: false,
12425 outputs_consumed: vec![],
12426 consumed_by: vec![],
12427 beef: Some(beef),
12428 block_height: None,
12429 score: Some(1.0),
12430 })
12431 .await
12432 .unwrap();
12433
12434 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12435 managers.insert(
12436 "Hello".into(),
12437 Box::new(MockTopicManager::admitting(vec![0])),
12438 );
12439 let mut engine = Engine::new(
12440 managers,
12441 HashMap::new(),
12442 Box::new(storage),
12443 None,
12444 EngineConfig::default(),
12445 );
12446 if let Some(f) = fetcher {
12447 engine.set_ancestor_fetcher(f);
12448 }
12449 (engine, txid)
12450 }
12451
12452 #[tokio::test]
12453 async fn proofs_completion_uses_dedicated_courier() {
12454 let (_, txid) = proofless_beef();
12455 let shared = std::rc::Rc::new(StubFetcher {
12456 proof: None,
12457 calls: Cell::new(0),
12458 });
12459 let dedicated = StubFetcher {
12460 proof: Some(single_leaf_bump_hex(&txid, 850_000)),
12461 calls: Cell::new(0),
12462 };
12463 let (engine, _) = engine_with_proofless_output(Some(shared.clone())).await;
12464 assert_eq!(
12465 engine
12466 .complete_missing_proofs(1, 0)
12467 .await
12468 .unwrap()
12469 .completed,
12470 0
12471 );
12472 assert_eq!(
12473 engine
12474 .complete_missing_proofs_with(&dedicated, 1, 0)
12475 .await
12476 .unwrap()
12477 .completed,
12478 1
12479 );
12480 assert_eq!(shared.calls.get(), 1);
12481 assert_eq!(dedicated.calls.get(), 1);
12482 let output = engine
12483 .storage()
12484 .find_output(&txid, 0, Some("Hello"), None, true)
12485 .await
12486 .unwrap()
12487 .unwrap();
12488 assert!(
12489 crate::beef_limits::own_proof(output.beef.as_ref().unwrap(), &txid)
12490 .unwrap()
12491 .is_some()
12492 );
12493 }
12494
12495 #[tokio::test]
12496 async fn complete_missing_proofs_noop_without_fetcher() {
12497 let (engine, _txid) = engine_with_proofless_output(None).await;
12499 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12500 assert_eq!(summary, ProofCompletionSummary::default());
12501 }
12502
12503 #[tokio::test]
12504 async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
12505 let (_beef, txid) = proofless_beef();
12506 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12507 let fetcher = std::rc::Rc::new(StubFetcher {
12508 proof: Some(proof_hex),
12509 calls: Cell::new(0),
12510 });
12511 let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12512
12513 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12514 assert_eq!(summary.scanned, 1);
12515 assert_eq!(summary.proofless, 1);
12516 assert_eq!(
12517 summary.completed, 1,
12518 "the proofless BEEF should be completed"
12519 );
12520 assert_eq!(fetcher.calls.get(), 1);
12521
12522 let out = engine
12525 .storage()
12526 .find_output(&txid, 0, Some("Hello"), None, true)
12527 .await
12528 .unwrap()
12529 .unwrap();
12530 assert_eq!(out.block_height, Some(850_000));
12531 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
12532 assert!(
12533 beef.find_txid(&txid)
12534 .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
12535 "completed BEEF must prove the target tx"
12536 );
12537
12538 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
12541 assert_eq!(summary2.proofless, 0);
12542 assert_eq!(summary2.completed, 0);
12543 assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
12544 }
12545
12546 #[tokio::test]
12547 async fn complete_missing_proofs_skips_unconfirmed() {
12548 let fetcher = std::rc::Rc::new(StubFetcher {
12551 proof: None,
12552 calls: Cell::new(0),
12553 });
12554 let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12555 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12556 assert_eq!(summary.proofless, 1);
12557 assert_eq!(summary.completed, 0);
12558 assert_eq!(summary.still_unconfirmed, 1);
12559 assert_eq!(fetcher.calls.get(), 1);
12560 }
12561
12562 async fn engine_with_proofless_output_shared(
12567 fetcher: Option<std::rc::Rc<StubFetcher>>,
12568 ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
12569 let (beef, txid) = proofless_beef();
12570 let storage = std::rc::Rc::new(MemoryStorage::new());
12571 storage
12572 .insert_output(&Output {
12573 txid: txid.clone(),
12574 output_index: 0,
12575 output_script: vec![0x76],
12576 satoshis: 1_000_000_000,
12577 topic: "Hello".to_string(),
12578 spent: false,
12579 outputs_consumed: vec![],
12580 consumed_by: vec![],
12581 beef: Some(beef),
12582 block_height: None,
12583 score: Some(1.0),
12584 })
12585 .await
12586 .unwrap();
12587
12588 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12589 managers.insert(
12590 "Hello".into(),
12591 Box::new(MockTopicManager::admitting(vec![0])),
12592 );
12593 let mut engine = Engine::new(
12594 managers,
12595 HashMap::new(),
12596 Box::new(storage.clone()),
12597 None,
12598 EngineConfig::default(),
12599 );
12600 if let Some(f) = fetcher {
12601 engine.set_ancestor_fetcher(f);
12602 }
12603 (engine, txid, storage)
12604 }
12605
12606 #[tokio::test]
12607 async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
12608 let proof_hex = {
12612 let (_beef, txid) = proofless_beef();
12613 single_leaf_bump_hex(&txid, 850_000)
12614 };
12615 let fetcher = std::rc::Rc::new(StubFetcher {
12616 proof: Some(proof_hex),
12617 calls: Cell::new(0),
12618 });
12619 let (engine, _txid, storage) =
12620 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12621
12622 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12623 assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
12624 assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
12625
12626 storage.advance_clock(1800);
12631 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12632 assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
12633 assert_eq!(summary.completed, 1, "the backstop completes the proof");
12634 assert_eq!(fetcher.calls.get(), 1);
12635 }
12636
12637 #[tokio::test]
12638 async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
12639 let fetcher = std::rc::Rc::new(StubFetcher {
12645 proof: None,
12646 calls: Cell::new(0),
12647 });
12648 let (engine, txid, storage) =
12649 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12650
12651 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12654 engine
12655 .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
12656 .await
12657 .unwrap();
12658
12659 storage.advance_clock(1_000_000);
12662 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12663 assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
12664 assert_eq!(summary.completed, 0);
12665 assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
12666 }
12667
12668 #[tokio::test]
12669 async fn complete_missing_proofs_respects_limit() {
12670 let (beef1, txid1) = proofless_beef();
12672 let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
12674 let (beef2, txid2) = {
12675 use bsv_rs::transaction::{Beef, Transaction};
12676 let tx = Transaction::from_hex(raw2).unwrap();
12677 let id = tx.id();
12678 let mut b = Beef::new();
12679 b.merge_transaction(tx);
12680 (b.to_binary(), id)
12681 };
12682
12683 let storage = MemoryStorage::new();
12684 for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
12685 storage
12686 .insert_output(&Output {
12687 txid: txid.clone(),
12688 output_index: 0,
12689 output_script: vec![0x76],
12690 satoshis: 1000,
12691 topic: "Hello".to_string(),
12692 spent: false,
12693 outputs_consumed: vec![],
12694 consumed_by: vec![],
12695 beef: Some(beef),
12696 block_height: None,
12697 score: Some(1.0),
12698 })
12699 .await
12700 .unwrap();
12701 }
12702
12703 let fetcher = std::rc::Rc::new(StubFetcher {
12704 proof: None,
12705 calls: Cell::new(0),
12706 });
12707 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12708 managers.insert(
12709 "Hello".into(),
12710 Box::new(MockTopicManager::admitting(vec![0])),
12711 );
12712 let mut engine = Engine::new(
12713 managers,
12714 HashMap::new(),
12715 Box::new(storage),
12716 None,
12717 EngineConfig::default(),
12718 );
12719 engine.set_ancestor_fetcher(fetcher.clone());
12720
12721 let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
12722 assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
12723 }
12724
12725 #[tokio::test]
12726 async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
12727 use bsv_rs::transaction::{Beef, MerklePath, Transaction};
12736
12737 let storage = MemoryStorage::new();
12738
12739 let proven_raws = [
12744 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
12745 "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
12746 ];
12747 let mut proven_ids = Vec::new();
12748 for raw in proven_raws {
12749 let mut tx = Transaction::from_hex(raw).unwrap();
12750 let id = tx.id();
12751 tx.merkle_path =
12752 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
12753 let proven_beef = tx.to_beef(true).unwrap();
12756 assert_eq!(
12757 Beef::from_binary(&proven_beef)
12758 .unwrap()
12759 .find_txid(&id)
12760 .map(bsv_rs::transaction::BeefTx::has_proof),
12761 Some(true),
12762 "proven fixture row must carry its proof"
12763 );
12764 proven_ids.push(id.clone());
12765 storage
12766 .insert_output(&Output {
12767 txid: id,
12768 output_index: 0,
12769 output_script: vec![0x76],
12770 satoshis: 1000,
12771 topic: "Hello".to_string(),
12772 spent: false,
12773 outputs_consumed: vec![],
12774 consumed_by: vec![],
12775 beef: Some(proven_beef),
12776 block_height: Some(800_000),
12777 score: Some(1.0),
12778 })
12779 .await
12780 .unwrap();
12781 }
12782
12783 let fetcher = std::rc::Rc::new(StubFetcher {
12784 proof: None,
12785 calls: Cell::new(0),
12786 });
12787 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12788 managers.insert(
12789 "Hello".into(),
12790 Box::new(MockTopicManager::admitting(vec![0])),
12791 );
12792 let mut engine = Engine::new(
12793 managers,
12794 HashMap::new(),
12795 Box::new(storage),
12796 None,
12797 EngineConfig::default(),
12798 );
12799 engine.set_ancestor_fetcher(fetcher.clone());
12800
12801 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12804 assert_eq!(summary.scanned, proven_ids.len());
12805 assert_eq!(summary.proofless, 0);
12806 assert_eq!(summary.completed, 0);
12807 assert_eq!(
12808 summary.already_proven,
12809 proven_ids.len(),
12810 "every already-proven row is marked this pass"
12811 );
12812 assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
12813
12814 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
12817 assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
12818 assert_eq!(summary2.already_proven, 0);
12819 }
12820
12821 #[tokio::test]
12822 async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
12823 use bsv_rs::transaction::{MerklePath, Transaction};
12829
12830 let storage = MemoryStorage::new();
12831
12832 let proven_raws = [
12833 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
12834 ];
12835 for raw in proven_raws {
12836 let mut tx = Transaction::from_hex(raw).unwrap();
12837 let id = tx.id();
12838 tx.merkle_path =
12839 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
12840 let proven_beef = tx.to_beef(true).unwrap();
12841 storage
12842 .insert_output(&Output {
12843 txid: id,
12844 output_index: 0,
12845 output_script: vec![0x76],
12846 satoshis: 1000,
12847 topic: "Hello".to_string(),
12848 spent: false,
12849 outputs_consumed: vec![],
12850 consumed_by: vec![],
12851 beef: Some(proven_beef),
12852 block_height: Some(800_000),
12853 score: Some(1.0),
12854 })
12855 .await
12856 .unwrap();
12857 }
12858
12859 let (proofless, proofless_txid) = proofless_beef();
12861 storage
12862 .insert_output(&Output {
12863 txid: proofless_txid.clone(),
12864 output_index: 0,
12865 output_script: vec![0x76],
12866 satoshis: 1000,
12867 topic: "Hello".to_string(),
12868 spent: false,
12869 outputs_consumed: vec![],
12870 consumed_by: vec![],
12871 beef: Some(proofless),
12872 block_height: None,
12873 score: Some(1.0),
12874 })
12875 .await
12876 .unwrap();
12877
12878 let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
12879 let fetcher = std::rc::Rc::new(StubFetcher {
12880 proof: Some(proof_hex),
12881 calls: Cell::new(0),
12882 });
12883 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12884 managers.insert(
12885 "Hello".into(),
12886 Box::new(MockTopicManager::admitting(vec![0])),
12887 );
12888 let mut engine = Engine::new(
12889 managers,
12890 HashMap::new(),
12891 Box::new(storage),
12892 None,
12893 EngineConfig::default(),
12894 );
12895 engine.set_ancestor_fetcher(fetcher.clone());
12896
12897 let mut completed_total = 0;
12900 for _ in 0..5 {
12901 let s = engine.complete_missing_proofs(1, 0).await.unwrap();
12902 completed_total += s.completed;
12903 if completed_total > 0 {
12904 break;
12905 }
12906 }
12907 assert_eq!(
12908 completed_total, 1,
12909 "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
12910 );
12911
12912 let out = engine
12913 .storage()
12914 .find_output(&proofless_txid, 0, Some("Hello"), None, true)
12915 .await
12916 .unwrap()
12917 .unwrap();
12918 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
12919 assert_eq!(
12920 beef.find_txid(&proofless_txid)
12921 .map(bsv_rs::transaction::BeefTx::has_proof),
12922 Some(true),
12923 "the old proofless tx now carries its proof"
12924 );
12925 }
12926
12927 use crate::storage::StorageError;
12935 use std::rc::Rc;
12936
12937 struct FaultingStorage {
12942 inner: MemoryStorage,
12943 fail_insert_output: Cell<bool>,
12944 fail_find_output: Cell<bool>,
12945 }
12946
12947 impl FaultingStorage {
12948 fn new() -> Rc<Self> {
12949 Rc::new(Self {
12950 inner: MemoryStorage::new(),
12951 fail_insert_output: Cell::new(false),
12952 fail_find_output: Cell::new(false),
12953 })
12954 }
12955 }
12956
12957 #[async_trait(?Send)]
12958 impl Storage for FaultingStorage {
12959 async fn put_deferred_graph(
12960 &self,
12961 record: &crate::gasp::DeferredGraph,
12962 ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
12963 self.inner.put_deferred_graph(record).await
12964 }
12965 async fn reserve_unanchorable_retry(
12966 &self,
12967 record: &crate::gasp::DeferredGraph,
12968 ) -> Result<bool, StorageError> {
12969 self.inner.reserve_unanchorable_retry(record).await
12970 }
12971 async fn find_deferred_graphs(
12972 &self,
12973 host: &str,
12974 topic: &str,
12975 ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
12976 self.inner.find_deferred_graphs(host, topic).await
12977 }
12978 async fn get_deferred_graph(
12979 &self,
12980 host: &str,
12981 topic: &str,
12982 outpoint: &str,
12983 ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
12984 self.inner.get_deferred_graph(host, topic, outpoint).await
12985 }
12986 async fn delete_deferred_graph(
12987 &self,
12988 host: &str,
12989 topic: &str,
12990 outpoint: &str,
12991 ) -> Result<(), StorageError> {
12992 self.inner
12993 .delete_deferred_graph(host, topic, outpoint)
12994 .await
12995 }
12996 async fn record_peer_sync_outcome(
12997 &self,
12998 host: &str,
12999 topic: &str,
13000 success: bool,
13001 ) -> Result<(), StorageError> {
13002 self.inner
13003 .record_peer_sync_outcome(host, topic, success)
13004 .await
13005 }
13006 async fn get_peer_sync_health(
13007 &self,
13008 host: &str,
13009 topic: &str,
13010 ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
13011 self.inner.get_peer_sync_health(host, topic).await
13012 }
13013 async fn record_peer_sync_yield(
13014 &self,
13015 host: &str,
13016 topic: &str,
13017 yielded: bool,
13018 ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
13019 self.inner
13020 .record_peer_sync_yield(host, topic, yielded)
13021 .await
13022 }
13023 async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
13024 if self.fail_insert_output.get() {
13025 return Err(StorageError::Database(
13026 "D1_ERROR: storage overloaded".into(),
13027 ));
13028 }
13029 self.inner.insert_output(output).await
13030 }
13031 async fn delete_output(
13032 &self,
13033 txid: &str,
13034 output_index: u32,
13035 topic: &str,
13036 ) -> Result<(), StorageError> {
13037 self.inner.delete_output(txid, output_index, topic).await
13038 }
13039 async fn mark_utxo_as_spent(
13040 &self,
13041 txid: &str,
13042 output_index: u32,
13043 topic: &str,
13044 ) -> Result<(), StorageError> {
13045 self.inner
13046 .mark_utxo_as_spent(txid, output_index, topic)
13047 .await
13048 }
13049 async fn update_consumed_by(
13050 &self,
13051 txid: &str,
13052 output_index: u32,
13053 topic: &str,
13054 consumed_by: &[Outpoint],
13055 ) -> Result<(), StorageError> {
13056 self.inner
13057 .update_consumed_by(txid, output_index, topic, consumed_by)
13058 .await
13059 }
13060 async fn update_transaction_beef(
13061 &self,
13062 txid: &str,
13063 beef: &[u8],
13064 ) -> Result<(), StorageError> {
13065 self.inner.update_transaction_beef(txid, beef).await
13066 }
13067 async fn insert_applied_transaction(
13068 &self,
13069 tx: &AppliedTransaction,
13070 ) -> Result<(), StorageError> {
13071 self.inner.insert_applied_transaction(tx).await
13072 }
13073 async fn does_applied_transaction_exist(
13074 &self,
13075 tx: &AppliedTransaction,
13076 ) -> Result<bool, StorageError> {
13077 self.inner.does_applied_transaction_exist(tx).await
13078 }
13079 async fn delete_applied_transaction(
13080 &self,
13081 tx: &AppliedTransaction,
13082 ) -> Result<(), StorageError> {
13083 self.inner.delete_applied_transaction(tx).await
13084 }
13085 async fn find_output(
13086 &self,
13087 txid: &str,
13088 output_index: u32,
13089 topic: Option<&str>,
13090 spent: Option<bool>,
13091 include_beef: bool,
13092 ) -> Result<Option<Output>, StorageError> {
13093 if self.fail_find_output.get() {
13094 return Err(StorageError::Database("D1_ERROR: read timed out".into()));
13095 }
13096 self.inner
13097 .find_output(txid, output_index, topic, spent, include_beef)
13098 .await
13099 }
13100 async fn find_outputs_for_transaction(
13101 &self,
13102 txid: &str,
13103 include_beef: bool,
13104 ) -> Result<Vec<Output>, StorageError> {
13105 self.inner
13106 .find_outputs_for_transaction(txid, include_beef)
13107 .await
13108 }
13109 async fn find_utxos_for_topic(
13110 &self,
13111 topic: &str,
13112 since: Option<f64>,
13113 limit: Option<u64>,
13114 include_beef: bool,
13115 ) -> Result<Vec<Output>, StorageError> {
13116 self.inner
13117 .find_utxos_for_topic(topic, since, limit, include_beef)
13118 .await
13119 }
13120 async fn update_last_interaction(
13121 &self,
13122 host: &str,
13123 topic: &str,
13124 since: u64,
13125 ) -> Result<(), StorageError> {
13126 self.inner.update_last_interaction(host, topic, since).await
13127 }
13128 async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
13129 self.inner.get_last_interaction(host, topic).await
13130 }
13131 }
13132
13133 fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
13134 let storage = FaultingStorage::new();
13135 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13136 managers.insert(
13137 "tm_test".to_string(),
13138 Box::new(MockTopicManager::admitting(admit_indices)),
13139 );
13140 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13141 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
13142 let engine = Engine::new(
13143 managers,
13144 lookup_services,
13145 Box::new(Rc::clone(&storage)),
13146 None,
13147 EngineConfig::default(),
13148 );
13149 (engine, storage)
13150 }
13151
13152 async fn applied(storage: &FaultingStorage) -> bool {
13153 storage
13154 .does_applied_transaction_exist(&AppliedTransaction {
13155 txid: TEST_TXID.to_string(),
13156 topic: "tm_test".to_string(),
13157 })
13158 .await
13159 .unwrap()
13160 }
13161
13162 #[tokio::test]
13165 async fn clean_submit_is_durable_and_records_applied() {
13166 let (engine, storage) = make_faulting_engine(vec![0]);
13167 let (steak, report) = engine
13168 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13169 .await
13170 .unwrap();
13171 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
13172 assert!(report.is_durable(), "{}", report.summary());
13173 assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
13174 assert!(applied(&storage).await);
13175 assert!(storage
13176 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13177 .await
13178 .unwrap()
13179 .is_some());
13180 }
13181
13182 #[tokio::test]
13187 async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
13188 let (engine, storage) = make_faulting_engine(vec![0]);
13189 storage.fail_insert_output.set(true);
13190 let (steak, report) = engine
13191 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13192 .await
13193 .unwrap();
13194 assert_eq!(
13195 steak["tm_test"].outputs_to_admit,
13196 vec![0],
13197 "the admission DECISION is unchanged by a write fault"
13198 );
13199 assert!(!report.is_durable());
13200 assert_eq!(report.faults.len(), 1, "{}", report.summary());
13201 assert_eq!(report.faults[0].topic, "tm_test");
13202 assert_eq!(report.faults[0].site, "insert_output");
13203 assert!(report.faults[0].error.contains("D1_ERROR"));
13204 assert!(report.applied_topics.is_empty());
13205 assert!(
13206 !applied(&storage).await,
13207 "a faulted topic must not be recorded as applied — the replay would be a dupe"
13208 );
13209 assert!(storage
13210 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13211 .await
13212 .unwrap()
13213 .is_none());
13214 }
13215
13216 #[tokio::test]
13220 async fn replay_after_a_fault_reapplies_and_records_applied() {
13221 let (engine, storage) = make_faulting_engine(vec![0]);
13222 storage.fail_insert_output.set(true);
13223 let (_, first) = engine
13224 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13225 .await
13226 .unwrap();
13227 assert!(!first.is_durable());
13228
13229 storage.fail_insert_output.set(false);
13230 let (steak, replay) = engine
13231 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13232 .await
13233 .unwrap();
13234 assert_eq!(
13235 steak["tm_test"].outputs_to_admit,
13236 vec![0],
13237 "re-validated, not deduped"
13238 );
13239 assert!(replay.is_durable(), "{}", replay.summary());
13240 assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
13241 assert!(applied(&storage).await);
13242 assert!(storage
13243 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13244 .await
13245 .unwrap()
13246 .is_some());
13247
13248 let (steak, third) = engine
13249 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13250 .await
13251 .unwrap();
13252 assert!(
13253 steak["tm_test"].outputs_to_admit.is_empty(),
13254 "now a genuine dupe: nothing admitted"
13255 );
13256 assert!(third.is_durable() && third.applied_topics.is_empty());
13257 }
13258
13259 #[tokio::test]
13265 async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
13266 let (engine, storage) = make_faulting_engine(vec![0]);
13267 let tx = Transaction::from_beef(&test_beef(), None).unwrap();
13270 let source_txid = tx.inputs[0].get_source_txid().unwrap();
13271 let source_vout = tx.inputs[0].source_output_index;
13272 storage
13273 .insert_output(&Output {
13274 txid: source_txid.clone(),
13275 output_index: source_vout,
13276 output_script: vec![0x51],
13277 satoshis: 1,
13278 topic: "tm_test".to_string(),
13279 spent: false,
13280 outputs_consumed: vec![],
13281 consumed_by: vec![],
13282 beef: None,
13283 block_height: None,
13284 score: Some(1.0),
13285 })
13286 .await
13287 .unwrap();
13288
13289 storage.fail_find_output.set(true);
13290 let (_, faulted) = engine
13291 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13292 .await
13293 .unwrap();
13294 assert!(!faulted.is_durable());
13295 assert!(
13296 faulted.faults.iter().any(|f| f.site == "find_output"),
13297 "{}",
13298 faulted.summary()
13299 );
13300 assert!(
13301 !applied(&storage).await,
13302 "unrecorded: the replay must re-read"
13303 );
13304 storage.fail_find_output.set(false);
13305 assert!(
13306 storage
13307 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13308 .await
13309 .unwrap()
13310 .is_some(),
13311 "the coin the faulted pass could not see is still there, untouched"
13312 );
13313
13314 let (_, replay) = engine
13315 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13316 .await
13317 .unwrap();
13318 assert!(replay.is_durable(), "{}", replay.summary());
13319 assert!(applied(&storage).await);
13320 assert!(
13323 storage
13324 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13325 .await
13326 .unwrap()
13327 .is_none(),
13328 "the healthy replay consumed the coin the faulted pass missed"
13329 );
13330 }
13331
13332 #[tokio::test]
13339 async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
13340 use std::cell::RefCell;
13341 use std::rc::Rc;
13342
13343 struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
13344 #[async_trait(?Send)]
13345 impl LookupServiceTrait for CountingLs {
13346 fn admission_mode(&self) -> AdmissionMode {
13347 self.1
13348 }
13349 fn spend_notification_mode(&self) -> SpendNotificationMode {
13350 SpendNotificationMode::None
13351 }
13352 async fn output_admitted_by_topic(
13353 &self,
13354 payload: &OutputAdmittedByTopic,
13355 ) -> Result<(), LookupServiceError> {
13356 let rec = match payload {
13357 OutputAdmittedByTopic::LockingScript {
13358 txid,
13359 output_index,
13360 topic,
13361 ..
13362 } => (txid.clone(), *output_index, topic.clone()),
13363 OutputAdmittedByTopic::WholeTx {
13364 atomic_beef,
13365 output_index,
13366 topic,
13367 ..
13368 } => {
13369 let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
13370 (
13371 beef.atomic_txid
13372 .clone()
13373 .expect("the WholeTx body NAMES its subject"),
13374 *output_index,
13375 topic.clone(),
13376 )
13377 }
13378 };
13379 self.0.borrow_mut().push(rec);
13380 Ok(())
13381 }
13382 async fn output_evicted(
13383 &self,
13384 _txid: &str,
13385 _output_index: u32,
13386 ) -> Result<(), LookupServiceError> {
13387 Ok(())
13388 }
13389 async fn lookup(
13390 &self,
13391 _question: &LookupQuestion,
13392 ) -> Result<LookupResult, LookupServiceError> {
13393 Ok(LookupResult::OutputList(Vec::new()))
13394 }
13395 async fn get_documentation(&self) -> String {
13396 "counting".to_string()
13397 }
13398 async fn get_metadata(&self) -> ServiceMetadata {
13399 ServiceMetadata {
13400 name: "counting-ls".to_string(),
13401 ..Default::default()
13402 }
13403 }
13404 }
13405
13406 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13407 managers.insert(
13408 "tm_test".to_string(),
13409 Box::new(MockTopicManager::admitting(vec![0])),
13410 );
13411 let seen = Rc::new(RefCell::new(Vec::new()));
13412 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13413 lookup_services.insert(
13414 "ls_whole".to_string(),
13415 Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
13416 );
13417 lookup_services.insert(
13418 "ls_script".to_string(),
13419 Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
13420 );
13421 let engine = Engine::new(
13422 managers,
13423 lookup_services,
13424 Box::new(MemoryStorage::new()),
13425 None,
13426 EngineConfig::default(),
13427 );
13428
13429 let tagged = test_tagged_beef(vec!["tm_test"]);
13430 engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13431 assert_eq!(seen.borrow().len(), 2, "one admit per service");
13432 let (txid, vout, _) = seen.borrow()[0].clone();
13433 assert_eq!(txid, TEST_TXID);
13434
13435 let (_, report) = engine
13437 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13438 .await
13439 .unwrap();
13440 assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
13441 assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
13442
13443 let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
13445 assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
13446 assert_eq!(r.vouts, vec![vout]);
13447 assert_eq!(seen.borrow().len(), 4);
13448 assert!(seen.borrow()[2..]
13449 .iter()
13450 .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
13451
13452 let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
13454 assert_eq!((r0.outputs, r0.notified), (0, 0));
13455 assert_eq!(seen.borrow().len(), 4);
13456
13457 let r2 = engine
13459 .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
13460 .await
13461 .unwrap();
13462 assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
13463 assert_eq!(seen.borrow().len(), 6);
13464 }
13465
13466 #[tokio::test]
13471 async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
13472 let engine = make_engine(vec![]);
13474 let tagged = test_tagged_beef(vec!["tm_test"]);
13475 let (steak, r1) = engine
13476 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13477 .await
13478 .unwrap();
13479 assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
13480 assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
13481 let (_, r2) = engine
13482 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13483 .await
13484 .unwrap();
13485 assert_eq!(
13486 r2.deduped_topics,
13487 vec!["tm_test".to_string()],
13488 "the phantom row dedups the re-submit"
13489 );
13490 assert!(engine
13491 .forget_phantom_applied(TEST_TXID, "tm_test")
13492 .await
13493 .unwrap());
13494 let (_, r3) = engine
13495 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13496 .await
13497 .unwrap();
13498 assert!(
13499 r3.deduped_topics.is_empty(),
13500 "judged again after the forget"
13501 );
13502 assert!(
13503 !engine
13504 .forget_phantom_applied(TEST_TXID, "tm_other")
13505 .await
13506 .unwrap(),
13507 "no row: nothing to forget"
13508 );
13509
13510 let real = make_engine(vec![0]);
13512 real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13513 assert!(!real
13514 .forget_phantom_applied(TEST_TXID, "tm_test")
13515 .await
13516 .unwrap());
13517 let (_, r4) = real
13518 .submit_with_report(&tagged, SubmitMode::CurrentTx)
13519 .await
13520 .unwrap();
13521 assert_eq!(
13522 r4.deduped_topics,
13523 vec!["tm_test".to_string()],
13524 "a real admission still dedups"
13525 );
13526 }
13527
13528 const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
13534
13535 const WIDE_HEIGHT: u32 = 800_001;
13537
13538 fn wide_leaves(k: u32) -> Vec<String> {
13540 (0..1u32 << k)
13541 .map(|i| {
13542 let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
13543 id.reverse();
13544 hex::encode(id)
13545 })
13546 .collect()
13547 }
13548
13549 fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
13553 let k = txids.len().trailing_zeros() as usize;
13554 let mut levels = vec![Vec::new(); k];
13555 levels[0] = txids
13556 .iter()
13557 .enumerate()
13558 .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
13559 .collect();
13560 let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
13561 let root = bump.compute_root(None).unwrap();
13562 (bump, root)
13563 }
13564
13565 fn w1_bytes(k: u32) -> (Vec<u8>, String) {
13568 let (bump, root) = wide_bump(&wide_leaves(k));
13569 let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
13570 beef.extend_from_slice(&bump.to_binary());
13571 beef.push(0x00);
13572 (beef, root)
13573 }
13574
13575 fn walk_within_bound(
13579 bytes: Vec<u8>,
13580 root: String,
13581 verify_scripts: bool,
13582 subject: String,
13583 what: &str,
13584 ) -> (Result<(), String>, std::time::Duration) {
13585 let (tx, rx) = std::sync::mpsc::channel();
13586 std::thread::spawn(move || {
13587 let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
13588 tracker.add_root(WIDE_HEIGHT, root);
13589 let runtime = tokio::runtime::Builder::new_current_thread()
13590 .build()
13591 .unwrap();
13592 let started = std::time::Instant::now();
13593 let verdict = runtime.block_on(verify_spv_like_the_reference(
13594 Some(&tracker),
13595 verify_scripts,
13596 &bytes,
13597 &subject,
13598 DoorBudget::DEFAULT,
13599 ));
13600 let verdict = match verdict {
13601 Ok(None) => Ok(()),
13602 Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
13603 Err(e) => Err(e.to_string()),
13604 };
13605 let _ = tx.send((verdict, started.elapsed()));
13606 });
13607 rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
13608 panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
13609 })
13610 }
13611
13612 #[test]
13616 fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
13617 for k in [12u32, 13] {
13618 let (bytes, root) = w1_bytes(k);
13619 let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
13620 let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
13621 println!(
13622 "NL-6e W1 {what}: {verdict:?} in {:.3} s",
13623 took.as_secs_f64()
13624 );
13625 assert_eq!(verdict, Ok(()), "{what}");
13626 }
13627 }
13628
13629 fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
13634 use bsv_rs::script::{LockingScript, UnlockingScript};
13635 use bsv_rs::transaction::{TransactionInput, TransactionOutput};
13636 let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
13637 let spending = |txid: &str| {
13638 let mut input = TransactionInput::new(txid.to_string(), 0);
13639 input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
13640 input
13641 };
13642 let parents: Vec<Transaction> = (0..1u32 << k)
13643 .map(|i| {
13644 let mut tx = Transaction::new();
13645 let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
13647 input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
13648 tx.inputs.push(input);
13649 tx.outputs.push(TransactionOutput::new(1, op_true()));
13650 tx
13651 })
13652 .collect();
13653 let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
13654 let (bump, root) = wide_bump(&ids);
13655 let mut beef = Beef::new();
13656 let at = beef.merge_bump(bump);
13657 for parent in &parents {
13658 beef.merge_raw_tx(parent.to_binary(), Some(at));
13659 }
13660 let mut layer = ids;
13661 while layer.len() > 1 {
13662 layer = layer
13663 .chunks(2)
13664 .map(|pair| {
13665 let mut tx = Transaction::new();
13666 tx.inputs.push(spending(&pair[0]));
13667 tx.inputs.push(spending(&pair[1]));
13668 tx.outputs.push(TransactionOutput::new(0, op_true()));
13669 beef.merge_raw_tx(tx.to_binary(), None);
13670 tx.id()
13671 })
13672 .collect();
13673 }
13674 (beef.to_binary(), root, layer.remove(0))
13675 }
13676
13677 #[test]
13678 fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
13679 for k in [12u32, 13] {
13680 let (bytes, root, subject) = proven_fan_in(k);
13681 let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
13682 let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
13683 println!(
13684 "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
13685 took.as_secs_f64()
13686 );
13687 assert_eq!(verdict, Ok(()), "{what}");
13688 }
13689 }
13690 include!("minutes_tests.rs");
13691}