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,
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 use crate::gasp::{GASPSync, ENGINE_GASP_SYNC_PAGE_LIMIT};
4948 use crate::gasp_overlay::OverlayGASPStorage;
4949
4950 if self.config.sync_configuration.is_empty() {
4951 info!("[GASP SYNC] No sync configuration — nothing to sync");
4952 return Ok(GASPSyncResult {
4953 topics_synced: HashMap::new(),
4954 });
4955 }
4956
4957 let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
4958 let mut ship_lookup_hosts = None;
4961 let mut topic_order = sync_order(&self.config.sync_configuration);
4962 topic_order.sort_by_key(|(topic, target)| {
4965 !(matches!(target, SyncTarget::Ship)
4966 && self
4967 .ship_bootstrap_peers
4968 .get(topic.as_str())
4969 .is_some_and(|peers| !peers.is_empty()))
4970 });
4971
4972 for (topic, target) in topic_order {
4973 let configured_peers = matches!(target, SyncTarget::Peers(_));
4976 let (peers, sync_type) = match target {
4977 SyncTarget::Disabled => {
4978 info!("[GASP SYNC] Topic {topic} is disabled — skipping");
4979 continue;
4980 }
4981 SyncTarget::Ship => {
4982 info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
4983 let peers = self
4984 .discover_ship_peers(topic, &mut ship_lookup_hosts)
4985 .await;
4986 info!(
4987 "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
4988 peers.len()
4989 );
4990 (peers, "ship".to_string())
4991 }
4992 SyncTarget::Peers(peer_urls) => {
4993 info!(
4994 "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
4995 peer_urls.len()
4996 );
4997 let peers: Vec<String> = peer_urls
4998 .iter()
4999 .filter(|url| {
5000 if let Some(ref our_url) = self.config.hosting_url {
5001 url.trim_end_matches('/') != our_url.trim_end_matches('/')
5002 } else {
5003 true
5004 }
5005 })
5006 .cloned()
5007 .collect();
5008
5009 info!(
5010 "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
5011 peers.len()
5012 );
5013 (peers, "peers".to_string())
5014 }
5015 };
5016
5017 let mut errors = Vec::new();
5018 let mut pruned_inputs: u64 = 0;
5019 let mut discarded_graphs: u64 = 0;
5020 let mut expired_refused: u64 = 0;
5021 let mut roots_proven = 0;
5022 let mut roots_unproven = 0;
5023 let mut cursor_held_by = Vec::new();
5024 let expired = std::rc::Rc::default();
5025 let mut anchor_walk_held_graphs: u64 = 0;
5026 let mut finalized_graphs: u64 = 0;
5027 let mut deadline_dropped_graphs: u64 = 0;
5028 let mut cursor_moves: Vec<CursorMove> = Vec::new();
5029 let mut deferred_graphs: u64 = 0;
5030 let mut resumed_graphs: u64 = 0;
5031 let mut converged_graphs: u64 = 0;
5032 let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
5033 let mut stalled_graphs: u64 = 0;
5034 let mut held_back_graphs: u64 = 0;
5035
5036 if let Some(ref factory) = self.gasp_remote_factory {
5038 for peer_url in &peers {
5039 let health_key = crate::gasp::peer_origin(peer_url);
5052 let health = self
5053 .storage
5054 .get_peer_sync_health(&health_key, topic)
5055 .await
5056 .unwrap_or_default();
5057 if crate::gasp::peer_sync_quarantined(&health) {
5058 warn!(
5059 "[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)",
5060 health.consecutive_failures,
5061 health.secs_since_last_attempt,
5062 crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5063 );
5064 continue;
5065 }
5066
5067 info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
5068
5069 let last_interaction = self
5071 .storage
5072 .get_last_interaction(peer_url, topic)
5073 .await
5074 .unwrap_or(0);
5075
5076 let sink = crate::gasp_overlay::new_finalized_graph_sink();
5078
5079 let sync_fetcher = self
5088 .gasp_ancestor_fetcher
5089 .as_ref()
5090 .or(self.ancestor_fetcher.as_ref());
5091 let hydration_on = sync_fetcher.is_some();
5092
5093 let mut gasp_storage =
5095 OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5096 .with_peer(peer_url.as_str())
5097 .with_configured_peer(configured_peers)
5098 .with_strict_beef(hydration_on)
5099 .with_script_verification(self.verify_scripts)
5100 .with_walk_budget(self.walk_budget);
5101 if let Some(manager) = self.managers.get(topic) {
5102 gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5103 }
5104 if let Some(tracker) = self.chain_tracker.as_deref() {
5109 gasp_storage = gasp_storage.with_chain_tracker(tracker);
5110 }
5111 let gasp_remote = factory.create_remote(peer_url, topic);
5112
5113 let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5114 let peer_finalized = std::cell::Cell::new(0u64);
5116 let mut sync = GASPSync::new(
5117 Box::new(gasp_storage),
5118 gasp_remote,
5119 last_interaction,
5120 &log_prefix,
5121 true, )
5123 .with_ancestor_fetcher(sync_fetcher.cloned())
5124 .with_expired_outpoints(std::rc::Rc::clone(&expired));
5125 if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5128 let (sleep, budget_ms) = (sleep.clone(), *budget_ms);
5129 let half_sleep = sleep.clone();
5130 sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5131 max_calls: *max_calls,
5132 max_bytes_fetched: self.graph_budget_limbs.0,
5133 max_nodes: self.graph_budget_limbs.1,
5134 deadline: Box::new(move || sleep(budget_ms)),
5135 half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5136 });
5137 }
5138 if self.peer_sync_budget.is_some() {
5143 sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5144 engine: self,
5145 sink: sink.clone(),
5146 peer_url,
5147 topic,
5148 submitted: &peer_finalized,
5149 }));
5150 }
5151
5152 let sync_outcome = match &self.peer_sync_budget {
5162 Some((sleep, budget_ms)) => {
5163 crate::gasp::race_or_deadline_guarded(
5164 sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5165 sleep(*budget_ms),
5166 &self.finalize_gate,
5167 )
5168 .await
5169 }
5170 None => Some(sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)).await),
5171 };
5172
5173 let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5174 pruned_inputs += sync.pruned_inputs();
5180 expired_refused += sync.expired_refused();
5181 roots_proven += sync.roots_proven();
5182 roots_unproven += sync.roots_unproven();
5183 discarded_graphs += sync.discarded_graphs();
5188 anchor_walk_held_graphs += sync.anchor_walk_held();
5189 let ran = !matches!(sync_outcome, Some(Err(_)));
5193 let mut advanced_cursor = false;
5194 match sync_outcome {
5195 None => {
5196 let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5197 let msg = format!(
5198 "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5199 );
5200 warn!("[GASP SYNC] {msg}");
5201 errors.push(msg);
5202
5203 let in_flight = u64::from(sync.graph_in_flight());
5214 deadline_dropped_graphs += in_flight;
5215 sync.defer_in_flight().await;
5218 let completed = sync.completed_cursor();
5219 if completed > last_interaction {
5220 match self
5221 .storage
5222 .update_last_interaction(peer_url, topic, completed)
5223 .await
5224 {
5225 Ok(()) => cursor_moves.push(CursorMove {
5226 peer: peer_url.clone(),
5227 from: last_interaction,
5228 to: completed,
5229 }),
5230 Err(e) => warn!(
5231 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5232 ),
5233 }
5234 }
5235 advanced_cursor = completed > last_interaction;
5245 outcome_success = peer_finalized.get() > 0
5246 || (completed > last_interaction && sync.cursor_yielded())
5247 || sync.deferral_stats().progressed > 0;
5248 warn!(
5249 "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5250 peer_finalized.get(),
5251 completed.max(last_interaction)
5252 );
5253 }
5254 Some(Ok(())) => {
5255 let landed = self
5259 .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5260 .await;
5261 let stats = sync.deferral_stats();
5272 if stats.stalled > 0
5273 && stats.progressed == 0
5274 && peer_finalized.get() == 0
5275 && !(landed
5276 && sync.last_interaction > last_interaction
5277 && sync.cursor_yielded())
5278 {
5279 outcome_success = false;
5280 let msg = format!(
5281 "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5282 stats.stalled
5283 );
5284 warn!("[GASP SYNC] {msg}");
5285 errors.push(msg);
5286 }
5287
5288 advanced_cursor = landed && sync.last_interaction > last_interaction;
5307 if !landed {
5308 warn!(
5309 "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5310 );
5311 } else if sync.last_interaction > last_interaction {
5312 match self
5313 .storage
5314 .update_last_interaction(peer_url, topic, sync.last_interaction)
5315 .await
5316 {
5317 Ok(()) => cursor_moves.push(CursorMove {
5318 peer: peer_url.clone(),
5319 from: last_interaction,
5320 to: sync.last_interaction,
5321 }),
5322 Err(e) => warn!(
5323 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5324 ),
5325 }
5326 }
5327 info!(
5328 "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5329 sync.last_interaction
5330 );
5331 }
5332 Some(Err(e)) => {
5333 let msg = format!("{peer_url}: {e}");
5334 warn!("[GASP SYNC] Sync failed: {msg}");
5335 errors.push(msg);
5336 }
5337 }
5338 if ran && self.graph_budget.is_some() {
5349 let yielded =
5350 peer_finalized.get() > 0 || (advanced_cursor && sync.cursor_yielded());
5351 if yielded || sync.graphs_attempted() > 0 {
5352 match self
5353 .storage
5354 .record_peer_sync_yield(&health_key, topic, yielded)
5355 .await
5356 {
5357 Ok(streak)
5358 if !yielded
5359 && crate::gasp::yieldless_sync_failed(&streak)
5360 && outcome_success =>
5361 {
5362 outcome_success = false;
5363 let msg = format!(
5364 "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5365 streak.yieldless_syncs,
5366 streak.secs_since_first.unwrap_or(0),
5367 crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5368 crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5369 );
5370 warn!("[GASP SYNC] {msg}");
5371 errors.push(msg);
5372 }
5373 Ok(_) => {}
5374 Err(e) => warn!(
5375 "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5376 ),
5377 }
5378 }
5379 }
5380 if !advanced_cursor {
5381 if let Some(outpoint) = sync.cursor_held_by() {
5382 cursor_held_by.push(outpoint);
5383 }
5384 }
5385 finalized_graphs += peer_finalized.get();
5386 let deferral = sync.deferral_stats();
5387 deferred_graphs += deferral.deferred;
5388 resumed_graphs += deferral.resumed;
5389 converged_graphs += deferral.converged;
5390 stalled_graphs += deferral.stalled;
5391 held_back_graphs += deferral.held_back;
5392 dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5393 peer: peer_url.clone(),
5394 outpoint: d.outpoint,
5395 reason: d.reason.as_str().to_string(),
5396 }));
5397
5398 if outcome_success
5418 && !(peer_finalized.get() > 0 || (advanced_cursor && sync.cursor_yielded()))
5419 && self.graph_budget.is_some()
5420 && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5421 {
5422 info!(
5423 "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5424 health.consecutive_failures
5425 );
5426 } else if let Err(e) = self
5427 .storage
5428 .record_peer_sync_outcome(&health_key, topic, outcome_success)
5429 .await
5430 {
5431 warn!(
5432 "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5433 );
5434 }
5435 }
5436 }
5437
5438 topics_synced.insert(
5439 topic.clone(),
5440 TopicSyncResult {
5441 roots_proven,
5442 roots_unproven,
5443 cursor_held_by,
5444 expired_refused,
5445 peers,
5446 sync_type,
5447 errors,
5448 pruned_inputs,
5449 discarded_graphs,
5450 anchor_walk_held_graphs,
5451 finalized_graphs,
5452 deadline_dropped_graphs,
5453 cursor_moves,
5454 deferred_graphs,
5455 resumed_graphs,
5456 converged_graphs,
5457 dropped_graphs,
5458 stalled_graphs,
5459 held_back_graphs,
5460 },
5461 );
5462 }
5463
5464 info!(
5465 "[GASP SYNC] Peer discovery complete for {} topic(s)",
5466 topics_synced.len()
5467 );
5468
5469 let expired: u64 = topics_synced.values().map(|r| r.expired_refused).sum();
5470 if expired > 0 {
5471 info!("[GASP SYNC] gaspExpiredRefused={expired}");
5472 }
5473 Ok(GASPSyncResult { topics_synced })
5474 }
5475
5476 async fn submit_finalized_graphs(
5500 &self,
5501 sink: &crate::gasp_overlay::FinalizedGraphSink,
5502 peer_url: &str,
5503 topic: &str,
5504 submitted: &std::cell::Cell<u64>,
5505 ) -> bool {
5506 let finalized: Vec<_> = sink
5507 .lock()
5508 .unwrap_or_else(std::sync::PoisonError::into_inner)
5509 .drain(..)
5510 .collect();
5511
5512 let mut all_landed = true;
5513 for graph in &finalized {
5514 for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5515 self.finalize_gate.stop_if_due().await;
5516 if position == 0 {
5517 submitted.set(submitted.get() + 1);
5518 }
5519 let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5520 let outcome = {
5530 let _writing = self.finalize_gate.write_section();
5531 let bound = self
5532 .finalize_submit_budget
5533 .as_ref()
5534 .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5535 self.submit_bounded(
5536 &tagged,
5537 SubmitMode::HistoricalTxNoSpv,
5538 bound.as_ref(),
5539 true,
5540 WalkBreachPolicy::NotNow,
5541 )
5542 .await
5543 };
5544 let not_landed = match outcome {
5545 Ok((_, report)) => {
5546 let landed = report.is_durable()
5547 && (report.applied_topics.contains(&graph.topic)
5548 || report.deduped_topics.contains(&graph.topic));
5549 (!landed).then(|| {
5550 if report.is_durable() {
5551 "the topic manager failed on it".to_string()
5552 } else {
5553 format!("not durable: {}", report.summary())
5554 }
5555 })
5556 }
5557 Err(e) => Some(e.to_string()),
5558 };
5559 if let Some(why) = not_landed {
5560 warn!(
5561 "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
5562 graph.topic
5563 );
5564 all_landed = false;
5565 break;
5566 }
5567 }
5568 }
5569
5570 if !finalized.is_empty() {
5571 info!(
5572 "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
5573 finalized.len()
5574 );
5575 }
5576 all_landed
5577 }
5578
5579 pub async fn rerequest_held_proofs(
5585 &self,
5586 candidates: &[crate::proof_window::HeldOutput],
5587 progress: &std::cell::Cell<crate::proof_window::PeerProofSummary>,
5588 ) {
5589 use crate::proof_window::{PEER_PROOF_REQUEST_MS, PEER_PROOF_ROW_LIMIT};
5590 let Some(factory) = &self.gasp_remote_factory else {
5591 return;
5592 };
5593 let Some(tracker) = self.chain_tracker.as_deref() else {
5594 return;
5595 };
5596 let mut lookup_hosts = None;
5597 let mut topic_peers = HashMap::new();
5598 let mut landed = HashSet::new();
5599 for candidate in candidates.iter().take(PEER_PROOF_ROW_LIMIT) {
5600 if !self.managers.contains_key(&candidate.topic) {
5601 continue;
5602 }
5603 if let std::collections::hash_map::Entry::Vacant(entry) =
5604 topic_peers.entry(candidate.topic.clone())
5605 {
5606 let peers = match self.config.sync_configuration.get(&candidate.topic) {
5607 Some(SyncTarget::Ship) => {
5608 self.discover_ship_peers(&candidate.topic, &mut lookup_hosts)
5609 .await
5610 }
5611 Some(SyncTarget::Peers(peers)) => self.ship_peers_excluding_self(peers.clone()),
5612 _ => Vec::new(),
5613 };
5614 entry.insert(peers);
5615 }
5616 for peer in &topic_peers[&candidate.topic] {
5617 let remote = factory.create_remote(peer, &candidate.topic);
5618 let graph_id = format!("{}.{}", candidate.txid, candidate.output_index);
5619 let mut summary = progress.get();
5620 summary.requests += 1;
5621 progress.set(summary);
5622 let ask =
5623 remote.request_node(&graph_id, &candidate.txid, candidate.output_index, false);
5624 let response = if let Some((sleep, _)) = &self.peer_sync_budget {
5625 crate::gasp::race_or_deadline(ask, sleep(PEER_PROOF_REQUEST_MS)).await
5626 } else {
5627 Some(ask.await)
5628 };
5629 let Some(Ok(node)) = response else {
5630 let mut summary = progress.get();
5631 summary.faults += 1;
5632 progress.set(summary);
5633 continue;
5634 };
5635 if landed.contains(&candidate.txid) {
5636 continue;
5637 }
5638 let Some(proof_hex) = node.proof else {
5639 continue;
5640 };
5641 let Ok(mut proof) = beef_limits::merkle_path_from_hex(
5642 &proof_hex,
5643 beef_limits::COURIER_PROOF_MAX_BYTES,
5644 ) else {
5645 let mut summary = progress.get();
5646 summary.refused += 1;
5647 progress.set(summary);
5648 continue;
5649 };
5650 let valid = match proof.compute_root(Some(&candidate.txid)) {
5651 Ok(root) => tracker
5652 .is_valid_root_for_height(&root, proof.block_height)
5653 .await
5654 .unwrap_or(false),
5655 Err(_) => false,
5656 };
5657 if !valid {
5658 let mut summary = progress.get();
5659 summary.refused += 1;
5660 progress.set(summary);
5661 continue;
5662 }
5663 for leaf in &mut proof.path[0] {
5666 if leaf.hash.as_deref() == Some(&candidate.txid) {
5667 leaf.txid = true;
5668 }
5669 }
5670 match self.land_peer_proof(candidate, &proof).await {
5671 Ok(()) => {
5672 landed.insert(candidate.txid.clone());
5673 let mut summary = progress.get();
5674 summary.landed += 1;
5675 progress.set(summary);
5676 }
5677 Err(error) => {
5678 warn!(%error, "[proof window] verified proof could not be stored");
5679 let mut summary = progress.get();
5680 summary.faults += 1;
5681 progress.set(summary);
5682 }
5683 }
5684 }
5685 }
5686 }
5687
5688 async fn land_peer_proof(
5689 &self,
5690 candidate: &crate::proof_window::HeldOutput,
5691 proof: &bsv_rs::transaction::MerklePath,
5692 ) -> Result<(), EngineError> {
5693 let output = self
5694 .storage
5695 .find_output(
5696 &candidate.txid,
5697 candidate.output_index,
5698 Some(&candidate.topic),
5699 None,
5700 true,
5701 )
5702 .await
5703 .map_err(|e| EngineError::StorageError(e.to_string()))?
5704 .ok_or(EngineError::NodeNotFound)?;
5705 let beef = output
5706 .beef
5707 .ok_or_else(|| EngineError::BeefParseError("missing stored BEEF".into()))?;
5708 let stitched = Self::stitch_proof_into_stored_beef(&beef, &candidate.txid, proof)
5709 .ok_or_else(|| {
5710 EngineError::BeefParseError("proof does not stitch into stored subject".into())
5711 })?;
5712 self.storage
5713 .update_transaction_beef(&candidate.txid, &stitched)
5714 .await
5715 .map_err(|e| EngineError::StorageError(e.to_string()))?;
5716 self.storage
5717 .mark_transaction_proven_at(&candidate.txid, Some(u64::from(proof.block_height)))
5718 .await
5719 .map_err(|e| EngineError::StorageError(e.to_string()))
5720 }
5721
5722 async fn discover_ship_peers(
5723 &self,
5724 topic: &str,
5725 lookup_hosts: &mut Option<Vec<String>>,
5726 ) -> Vec<String> {
5727 let mut domains = self.local_ship_peers(topic).await;
5728 if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
5729 domains.extend(bootstrap.iter().cloned());
5730 }
5731 if let Some(client) = &self.ship_lookup_client {
5732 if lookup_hosts.is_none() {
5733 *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
5734 }
5735 let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
5736 for host in lookup_hosts.as_ref().into_iter().flatten() {
5737 match client.lookup(host, &question).await {
5738 Ok(outputs) => {
5739 domains.extend(outputs.iter().filter_map(|output| {
5740 parse_lookup_advertisement(output, b"SHIP", topic)
5741 }));
5742 }
5743 Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
5744 }
5745 }
5746 }
5747 self.ship_peers_excluding_self(domains)
5748 }
5749
5750 async fn discover_ship_lookup_hosts(
5751 &self,
5752 client: &dyn crate::ship_lookup::ShipLookupClient,
5753 ) -> Vec<String> {
5754 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
5755 let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
5756 let tracker_origins: HashSet<_> = trackers
5757 .iter()
5758 .map(|host| crate::gasp::peer_origin(host))
5759 .collect();
5760 let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
5761 let mut advertised = Vec::new();
5762 for tracker in &trackers {
5763 match client.lookup(tracker, &question).await {
5764 Ok(outputs) => {
5765 advertised.extend(outputs.iter().filter_map(|output| {
5766 parse_lookup_advertisement(output, b"SLAP", "ls_ship")
5767 }));
5768 }
5769 Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
5770 }
5771 }
5772 let providers = self
5773 .ship_peers_excluding_self(advertised)
5774 .into_iter()
5775 .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
5776 .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
5777 trackers.into_iter().chain(providers).collect()
5778 }
5779
5780 fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
5781 crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
5782 self.config.hosting_url.as_ref().is_none_or(|our_url| {
5783 crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
5784 })
5785 }))
5786 }
5787
5788 async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
5790 let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
5791 warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
5792 return Vec::new();
5793 };
5794
5795 let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
5796
5797 let refs = match ship_ls.lookup(&question).await {
5798 Ok(LookupResult::OutputList(refs)) => refs,
5799 Ok(LookupResult::Answer(_)) => {
5800 error!(
5801 "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
5802 LookupAnswer; expected OutputList. Skipping."
5803 );
5804 return Vec::new();
5805 }
5806 Err(e) => {
5807 error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
5808 return Vec::new();
5809 }
5810 };
5811
5812 let mut domains = HashSet::new();
5813 for reference in &refs {
5814 if let Ok(Some(output)) = self
5815 .storage
5816 .find_output(&reference.txid, reference.output_index, None, None, false)
5817 .await
5818 {
5819 if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
5820 if let Some(ref our_url) = self.config.hosting_url {
5824 if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
5825 || crate::gasp::peer_origin(&domain)
5826 == crate::gasp::peer_origin(our_url)
5827 {
5828 continue;
5829 }
5830 }
5831 domains.insert(domain);
5832 }
5833 }
5834 }
5835
5836 crate::gasp::ship_peers_by_origin(domains)
5840 }
5841
5842 pub fn storage(&self) -> &dyn Storage {
5848 self.storage.as_ref()
5849 }
5850
5851 pub fn config(&self) -> &EngineConfig {
5853 &self.config
5854 }
5855}
5856
5857pub(crate) async fn verify_spv_like_the_reference(
5874 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5875 verify_scripts: bool,
5876 beef_bytes: &[u8],
5877 subject_txid: &str,
5878 budget: DoorBudget,
5879) -> Result<Option<WalkCouldNotRun>, EngineError> {
5880 verify_spv_trusting(
5881 chain_tracker,
5882 verify_scripts,
5883 beef_bytes,
5884 subject_txid,
5885 &HashSet::new(),
5886 budget,
5887 )
5888 .await
5889}
5890
5891async fn verify_spv_trusting(
5896 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5897 verify_scripts: bool,
5898 beef_bytes: &[u8],
5899 subject_txid: &str,
5900 trusted: &HashSet<String>,
5901 budget: DoorBudget,
5902) -> Result<Option<WalkCouldNotRun>, EngineError> {
5903 if verify_scripts {
5904 Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
5905 } else {
5906 Engine::verify_spv_structurally(chain_tracker, beef_bytes)
5907 .await
5908 .map(|()| None)
5909 }
5910}
5911
5912fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
5916 let mut proven: HashMap<&str, usize> = HashMap::new();
5917 for (at, bump) in beef.bumps.iter().enumerate() {
5918 for leaf in bump.path.first().into_iter().flatten() {
5919 if let Some(hash) = leaf.hash.as_deref() {
5920 proven.entry(hash).or_insert(at);
5921 }
5922 }
5923 }
5924 proven
5925}
5926
5927fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
5932 let bodies: HashMap<String, &Transaction> = beef
5933 .txs
5934 .iter()
5935 .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
5936 .collect();
5937 let proven = proven_index(beef);
5938 let mut cover = HashSet::new();
5939 let mut queue = vec![from.to_string()];
5940 while let Some(txid) = queue.pop() {
5941 if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
5942 continue;
5943 }
5944 if let Some(tx) = bodies.get(&txid) {
5945 queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
5946 }
5947 }
5948 cover
5949}
5950
5951struct SubmitAsFinalized<'e> {
5954 engine: &'e Engine,
5955 sink: crate::gasp_overlay::FinalizedGraphSink,
5956 peer_url: &'e str,
5957 topic: &'e str,
5958 submitted: &'e std::cell::Cell<u64>,
5959}
5960
5961#[async_trait::async_trait(?Send)]
5962impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
5963 async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
5964 let landed = self
5968 .engine
5969 .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
5970 .await;
5971 if landed {
5972 Ok(())
5973 } else {
5974 Err(crate::gasp::GASPError::StorageError(
5977 "a finalize submit did not land".to_string(),
5978 ))
5979 }
5980 }
5981}
5982
5983#[derive(Debug, Clone, Serialize, Deserialize)]
5987pub struct GASPSyncResult {
5988 pub topics_synced: HashMap<String, TopicSyncResult>,
5990}
5991
5992#[derive(Debug, Clone, Serialize, Deserialize)]
5994pub struct TopicSyncResult {
5995 #[serde(default)]
5997 pub roots_proven: u64,
5998 #[serde(default)]
5999 pub roots_unproven: u64,
6000 #[serde(default)]
6002 pub cursor_held_by: Vec<String>,
6003 #[serde(default)]
6005 pub expired_refused: u64,
6006 pub peers: Vec<String>,
6008 pub sync_type: String,
6010 pub errors: Vec<String>,
6016 #[serde(default)]
6022 pub pruned_inputs: u64,
6023 #[serde(default)]
6028 pub discarded_graphs: u64,
6029 #[serde(default)]
6035 pub anchor_walk_held_graphs: u64,
6036 #[serde(default)]
6046 pub finalized_graphs: u64,
6047 #[serde(default)]
6055 pub deadline_dropped_graphs: u64,
6056 #[serde(default)]
6061 pub cursor_moves: Vec<CursorMove>,
6062 #[serde(default)]
6066 pub deferred_graphs: u64,
6067 #[serde(default)]
6069 pub resumed_graphs: u64,
6070 #[serde(default)]
6073 pub converged_graphs: u64,
6074 #[serde(default)]
6076 pub dropped_graphs: Vec<DroppedDeferral>,
6077 #[serde(default)]
6082 pub stalled_graphs: u64,
6083 #[serde(default)]
6087 pub held_back_graphs: u64,
6088}
6089
6090#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6093pub struct DroppedDeferral {
6094 pub peer: String,
6096 pub outpoint: String,
6098 pub reason: String,
6100}
6101
6102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6105pub struct CursorMove {
6106 pub peer: String,
6108 pub from: u64,
6110 pub to: u64,
6112}
6113
6114pub(crate) fn current_timestamp_ms() -> f64 {
6119 #[cfg(target_arch = "wasm32")]
6120 {
6121 js_sys::Date::now()
6122 }
6123 #[cfg(not(target_arch = "wasm32"))]
6124 {
6125 std::time::SystemTime::now()
6126 .duration_since(std::time::UNIX_EPOCH)
6127 .map_or(0.0, |d| d.as_millis() as f64)
6128 }
6129}
6130
6131fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
6137 parse_advertisement_domain_from_script(output_script, b"SHIP", None)
6138}
6139
6140fn parse_lookup_advertisement(
6141 output: &OutputListItem,
6142 protocol: &[u8],
6143 scope: &str,
6144) -> Option<String> {
6145 let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
6146 .ok()?;
6147 let script = tx
6148 .outputs
6149 .get(output.output_index as usize)?
6150 .locking_script
6151 .to_binary();
6152 parse_advertisement_domain_from_script(&script, protocol, Some(scope))
6153}
6154
6155fn parse_advertisement_domain_from_script(
6156 output_script: &[u8],
6157 protocol: &[u8],
6158 scope: Option<&str>,
6159) -> Option<String> {
6160 use bsv_rs::script::templates::PushDrop;
6161
6162 let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
6163 let pushdrop = PushDrop::decode(&script.into()).ok()?;
6164
6165 if pushdrop.fields.len() < 3 {
6166 return None;
6167 }
6168
6169 if pushdrop.fields[0] != protocol {
6170 return None;
6171 }
6172
6173 if scope.is_some_and(|expected| {
6174 pushdrop
6175 .fields
6176 .get(3)
6177 .is_none_or(|actual| actual != expected.as_bytes())
6178 }) {
6179 return None;
6180 }
6181
6182 let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
6183 if domain.is_empty() {
6184 return None;
6185 }
6186
6187 Some(domain)
6188}
6189
6190#[derive(Debug, thiserror::Error)]
6199pub enum EngineError {
6200 #[error("This server does not support this topic: {0}")]
6201 UnsupportedTopic(String),
6202
6203 #[error("Lookup service not found for provider: {0}")]
6204 LookupServiceNotFound(String),
6205
6206 #[error("No matching output found!")]
6209 NodeNotFound,
6210
6211 #[error("lookup failed: {0}")]
6212 LookupFailed(String),
6213
6214 #[error("invalid query: {0}")]
6221 InvalidQuery(String),
6222
6223 #[error("storage error: {0}")]
6224 StorageError(String),
6225
6226 #[error("broadcast error: {0}")]
6227 BroadcastError(String),
6228
6229 #[error("SPV verification failed: {0}")]
6230 SpvError(String),
6231
6232 #[error("BEEF parsing failed: {0}")]
6233 BeefParseError(String),
6234
6235 #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6245 ScriptVerificationFailed {
6246 subject_txid: String,
6247 input_index: u32,
6248 reason: String,
6249 },
6250
6251 #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6258 ScriptWalkInconclusive {
6259 at_txid: String,
6260 subject_judged: bool,
6261 reason: String,
6262 },
6263
6264 #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6269 ScriptWalkOverBudget {
6270 at_txid: String,
6271 subject_judged: bool,
6272 limb: DoorLimb,
6273 what: String,
6274 },
6275
6276 #[error(
6286 "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6287 .0.limb.as_str(),
6288 .0.what
6289 )]
6290 WalkCouldNotRun(WalkCouldNotRun),
6291
6292 #[error("{0}")]
6293 Other(String),
6294}
6295
6296impl From<StorageError> for EngineError {
6297 fn from(e: StorageError) -> Self {
6298 EngineError::StorageError(e.to_string())
6299 }
6300}
6301
6302impl From<TopicManagerError> for EngineError {
6303 fn from(e: TopicManagerError) -> Self {
6304 EngineError::Other(e.to_string())
6305 }
6306}
6307
6308impl From<LookupServiceError> for EngineError {
6309 fn from(e: LookupServiceError) -> Self {
6310 EngineError::LookupFailed(e.to_string())
6311 }
6312}
6313
6314impl From<AdvertiserError> for EngineError {
6315 fn from(e: AdvertiserError) -> Self {
6316 EngineError::Other(e.to_string())
6317 }
6318}
6319
6320pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6330 let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6331 order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6332 order
6333}
6334
6335#[cfg(test)]
6336mod tests {
6337 use super::*;
6338 use crate::lookup_service::LookupService as LookupServiceTrait;
6339 use crate::storage::memory::MemoryStorage;
6340 use crate::topic_manager::TopicManager as TopicManagerTrait;
6341 use async_trait::async_trait;
6342 use std::sync::Mutex;
6343
6344 #[test]
6348 fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6349 let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6350 let config: SyncConfiguration = HashMap::from([
6351 ("tm_a".to_string(), SyncTarget::Ship),
6352 ("tm_z".to_string(), peers("https://z")),
6353 ("tm_b".to_string(), SyncTarget::Ship),
6354 ("tm_m".to_string(), peers("https://m")),
6355 ("tm_off".to_string(), SyncTarget::Disabled),
6356 ]);
6357 let order: Vec<&str> = sync_order(&config)
6358 .iter()
6359 .map(|(t, _)| t.as_str())
6360 .collect();
6361 assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6362 }
6363
6364 struct MockTopicManager {
6367 admit_indices: Vec<u32>,
6368 }
6369
6370 impl MockTopicManager {
6371 fn admitting(indices: Vec<u32>) -> Self {
6372 Self {
6373 admit_indices: indices,
6374 }
6375 }
6376 }
6377
6378 #[async_trait(?Send)]
6379 impl TopicManagerTrait for MockTopicManager {
6380 async fn identify_admissible_outputs(
6381 &self,
6382 _tx: &Transaction,
6383 _previous_coins: &[u8],
6384 _off_chain_values: Option<&[u8]>,
6385 _mode: SubmitMode,
6386 _context: &TopicAdmittanceContext,
6387 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6388 Ok(AdmittanceInstructions {
6389 outputs_to_admit: self.admit_indices.clone(),
6390 coins_to_retain: vec![],
6391 coins_removed: None,
6392 })
6393 }
6394
6395 async fn get_documentation(&self) -> String {
6396 "Mock topic manager".to_string()
6397 }
6398
6399 async fn get_metadata(&self) -> ServiceMetadata {
6400 ServiceMetadata {
6401 name: "mock-tm".to_string(),
6402 description: Some("Mock for testing".to_string()),
6403 ..Default::default()
6404 }
6405 }
6406 }
6407
6408 struct MockLookupService {
6411 records: Mutex<Vec<UTXOReference>>,
6412 }
6413
6414 impl MockLookupService {
6415 fn new() -> Self {
6416 Self {
6417 records: Mutex::new(Vec::new()),
6418 }
6419 }
6420 }
6421
6422 #[async_trait(?Send)]
6423 impl LookupServiceTrait for MockLookupService {
6424 fn admission_mode(&self) -> AdmissionMode {
6425 AdmissionMode::LockingScript
6426 }
6427
6428 fn spend_notification_mode(&self) -> SpendNotificationMode {
6429 SpendNotificationMode::None
6430 }
6431
6432 async fn output_admitted_by_topic(
6433 &self,
6434 payload: &OutputAdmittedByTopic,
6435 ) -> Result<(), LookupServiceError> {
6436 let (txid, oi) = match payload {
6437 OutputAdmittedByTopic::LockingScript {
6438 txid, output_index, ..
6439 } => (txid.clone(), *output_index),
6440 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
6441 ("whole".into(), *output_index)
6442 }
6443 };
6444 self.records.lock().unwrap().push(UTXOReference {
6445 txid,
6446 output_index: oi,
6447 });
6448 Ok(())
6449 }
6450
6451 async fn output_evicted(
6452 &self,
6453 txid: &str,
6454 output_index: u32,
6455 ) -> Result<(), LookupServiceError> {
6456 self.records
6457 .lock()
6458 .unwrap()
6459 .retain(|r| !(r.txid == txid && r.output_index == output_index));
6460 Ok(())
6461 }
6462
6463 async fn lookup(
6464 &self,
6465 _question: &LookupQuestion,
6466 ) -> Result<LookupResult, LookupServiceError> {
6467 Ok(LookupResult::OutputList(
6468 self.records.lock().unwrap().clone(),
6469 ))
6470 }
6471
6472 async fn get_documentation(&self) -> String {
6473 "Mock lookup service".to_string()
6474 }
6475
6476 async fn get_metadata(&self) -> ServiceMetadata {
6477 ServiceMetadata {
6478 name: "mock-ls".to_string(),
6479 ..Default::default()
6480 }
6481 }
6482 }
6483
6484 const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
6488 const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
6489
6490 fn decode_hex(hex: &str) -> Vec<u8> {
6491 (0..hex.len())
6492 .step_by(2)
6493 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
6494 .collect()
6495 }
6496
6497 fn test_beef() -> Vec<u8> {
6498 decode_hex(TEST_BEEF_HEX)
6499 }
6500
6501 fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
6502 TaggedBEEF::new(
6503 test_beef(),
6504 topics.into_iter().map(str::to_string).collect(),
6505 )
6506 }
6507
6508 fn make_engine(admit_indices: Vec<u32>) -> Engine {
6511 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6512 managers.insert(
6513 "tm_test".to_string(),
6514 Box::new(MockTopicManager::admitting(admit_indices)),
6515 );
6516
6517 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
6518 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
6519
6520 let storage = Box::new(MemoryStorage::new());
6521
6522 Engine::new(
6523 managers,
6524 lookup_services,
6525 storage,
6526 None,
6527 EngineConfig::default(),
6528 )
6529 }
6530
6531 #[tokio::test]
6540 async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
6541 use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
6542 use std::cell::RefCell;
6543 use std::rc::Rc;
6544
6545 let parsed = Beef::from_binary(&test_beef()).unwrap();
6548 let parent = parsed.txs[0].tx().unwrap().clone();
6549 let child = parsed.txs[1].tx().unwrap().clone();
6550 let child_id = child.id();
6551 let parent_id = parent.id();
6552 let mut beef = Beef::new();
6553 for bump in &parsed.bumps {
6559 beef.merge_bump(bump.clone());
6560 }
6561 beef.merge_transaction(child);
6562 beef.merge_transaction(parent);
6563 let mut w = bsv_rs::primitives::encoding::Writer::new();
6564 w.write_u32_le(ATOMIC_BEEF);
6565 let mut le = hex::decode(&child_id).unwrap();
6566 le.reverse();
6567 w.write_bytes(&le);
6568 beef.to_writer(&mut w);
6569 let atomic_wire_order = w.into_bytes();
6570
6571 struct RecordingTm(Rc<RefCell<Option<String>>>);
6573 #[async_trait(?Send)]
6574 impl TopicManagerTrait for RecordingTm {
6575 async fn identify_admissible_outputs(
6576 &self,
6577 tx: &Transaction,
6578 _previous_coins: &[u8],
6579 _off_chain_values: Option<&[u8]>,
6580 _mode: SubmitMode,
6581 _context: &TopicAdmittanceContext,
6582 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6583 *self.0.borrow_mut() = Some(tx.id());
6584 Ok(AdmittanceInstructions {
6585 outputs_to_admit: vec![0],
6586 coins_to_retain: vec![],
6587 coins_removed: None,
6588 })
6589 }
6590 async fn get_documentation(&self) -> String {
6591 String::new()
6592 }
6593 async fn get_metadata(&self) -> ServiceMetadata {
6594 ServiceMetadata {
6595 name: "tm_test".to_string(),
6596 ..Default::default()
6597 }
6598 }
6599 }
6600
6601 let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
6602 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6603 managers.insert(
6604 "tm_test".to_string(),
6605 Box::new(RecordingTm(Rc::clone(&seen))),
6606 );
6607 let engine = Engine::new(
6608 managers,
6609 HashMap::new(),
6610 Box::new(MemoryStorage::new()),
6611 None,
6612 EngineConfig::default(),
6613 );
6614
6615 let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
6616 let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
6617
6618 let handed = seen.borrow().clone().expect("TM was not invoked");
6619 assert_eq!(
6620 handed, child_id,
6621 "TM must be handed the atomic subject, got the wire-order pick"
6622 );
6623 assert_ne!(handed, parent_id);
6624 }
6625
6626 #[tokio::test]
6639 async fn history_hydration_preserves_the_stored_proof() {
6640 use bsv_rs::transaction::Beef;
6641
6642 let engine = make_engine(vec![0]);
6643 let stored = test_beef();
6644 let bumps_before = Beef::from_binary(&stored)
6645 .expect("fixture BEEF parses")
6646 .bumps
6647 .len();
6648 assert!(
6649 bumps_before > 0,
6650 "fixture must actually carry a proof, or this cell proves nothing"
6651 );
6652
6653 let output = Output {
6654 txid: TEST_TXID.to_string(),
6655 output_index: 0,
6656 output_script: vec![0x76, 0xa9],
6657 satoshis: 1000,
6658 topic: "tm_test".to_string(),
6659 spent: false,
6660 outputs_consumed: vec![],
6663 consumed_by: vec![],
6664 beef: Some(stored.clone()),
6665 block_height: None,
6666 score: Some(1000.0),
6667 };
6668
6669 let hydrated = engine
6670 .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
6671 .await
6672 .expect("hydration must not error")
6673 .expect("hydration must return the output");
6674 let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
6675 .expect("hydrated BEEF parses");
6676
6677 assert_eq!(
6678 beef.bumps.len(),
6679 bumps_before,
6680 "asking for history must not strip the merkle proof (issue #4)"
6681 );
6682 }
6683
6684 #[tokio::test]
6685 async fn test_submit_unsupported_topic_errors() {
6686 let engine = make_engine(vec![0]);
6687 let beef = test_tagged_beef(vec!["tm_nonexistent"]);
6688
6689 let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
6690 assert!(result.is_err());
6691 assert!(matches!(
6692 result.unwrap_err(),
6693 EngineError::UnsupportedTopic(_)
6694 ));
6695 }
6696
6697 #[tokio::test]
6698 async fn test_submit_admits_outputs() {
6699 let engine = make_engine(vec![0, 1]);
6700 let beef = test_tagged_beef(vec!["tm_test"]);
6701
6702 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6703
6704 let instructions = steak.get("tm_test").unwrap();
6706 assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
6707
6708 let txid = TEST_TXID;
6710 let found = engine
6711 .storage()
6712 .find_output(txid, 0, Some("tm_test"), None, true)
6713 .await
6714 .unwrap()
6715 .expect("Output 0 should be in storage");
6716
6717 assert_eq!(found.txid, TEST_TXID);
6718 assert_eq!(found.satoshis, 26172); assert!(
6720 !found.output_script.is_empty(),
6721 "Script should be populated"
6722 );
6723 assert!(found.beef.is_some(), "BEEF should be stored");
6724 assert!(!found.spent);
6725 }
6726
6727 #[tokio::test]
6728 async fn test_submit_duplicate_is_skipped() {
6729 let engine = make_engine(vec![0]);
6730 let beef = test_tagged_beef(vec!["tm_test"]);
6731
6732 let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6734 assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
6735
6736 let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6738 assert!(steak2["tm_test"].outputs_to_admit.is_empty());
6739 }
6740
6741 #[tokio::test]
6742 async fn test_submit_notifies_lookup_service() {
6743 let engine = make_engine(vec![0]);
6744 let beef = test_tagged_beef(vec!["tm_test"]);
6745
6746 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6747
6748 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6750 let answer = engine.lookup(&question, None).await.unwrap();
6751
6752 match answer {
6753 LookupAnswer::OutputList { outputs } => {
6754 assert_eq!(outputs.len(), 1);
6755 }
6756 _ => panic!("Expected OutputList"),
6757 }
6758 }
6759
6760 #[tokio::test]
6764 async fn test_lookup_with_txids_aligns_txids_with_outputs() {
6765 let engine = make_engine(vec![0]);
6766 let beef = test_tagged_beef(vec!["tm_test"]);
6767 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6768
6769 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6770 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
6771 match answer {
6772 LookupAnswer::OutputList { outputs } => {
6773 assert_eq!(outputs.len(), 1);
6774 assert_eq!(txids.len(), outputs.len(), "one txid per output");
6775 assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
6776 }
6777 _ => panic!("Expected OutputList"),
6778 }
6779 }
6780
6781 #[tokio::test]
6782 async fn proof_window_holds_an_aged_child_with_a_proven_parent() {
6783 let mut engine = make_engine(vec![0]);
6784 let storage = std::rc::Rc::new(MemoryStorage::new());
6785 engine.storage = Box::new(storage.clone());
6786 engine
6787 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
6788 .await
6789 .unwrap();
6790 assert!(beef_limits::own_proof(&test_beef(), TEST_TXID)
6791 .unwrap()
6792 .is_none());
6793 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6794 storage.advance_clock((crate::proof_window::PROOFLESS_FLAG_MS / 1000) as u64);
6795 assert_eq!(
6796 engine
6797 .lookup_with_txids(&question, None)
6798 .await
6799 .unwrap()
6800 .1
6801 .len(),
6802 1
6803 );
6804 storage.advance_clock(1);
6805 assert!(
6806 engine
6807 .lookup_with_txids(&question, None)
6808 .await
6809 .unwrap()
6810 .1
6811 .is_empty(),
6812 "an aged unproven child must be held even when its parent is proven"
6813 );
6814 }
6815
6816 #[tokio::test]
6817 async fn proof_window_peer_proof_verifies_then_restores_lookup() {
6818 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
6819 let mut engine = make_engine(vec![0]);
6820 let storage = std::rc::Rc::new(MemoryStorage::new());
6821 engine.storage = Box::new(storage.clone());
6822 engine
6823 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
6824 .await
6825 .unwrap();
6826 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
6827 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6828 let candidate = HeldOutput {
6829 txid: TEST_TXID.into(),
6830 output_index: 0,
6831 topic: "tm_test".into(),
6832 };
6833 let (_, mut remote) = budget_page_engine(1);
6834 remote.node_cost_ms = 0;
6835 remote.nodes = std::rc::Rc::new(HashMap::from([(
6836 TEST_TXID.into(),
6837 crate::types::GASPNode {
6838 graph_id: String::new(),
6839 raw_tx: String::new(),
6840 output_index: 0,
6841 proof: Some(single_leaf_bump_hex(TEST_TXID, 800_000)),
6842 tx_metadata: None,
6843 output_metadata: None,
6844 inputs: None,
6845 },
6846 )]));
6847 engine
6848 .config
6849 .sync_configuration
6850 .insert("tm_test".into(), SyncTarget::Ship);
6851 engine.ship_bootstrap_peers.insert(
6852 "tm_test".into(),
6853 vec!["https://a.example".into(), "https://b.example".into()],
6854 );
6855 engine.set_gasp_remote_factory(Box::new(remote));
6856 let progress = std::cell::Cell::new(PeerProofSummary::default());
6857 engine.chain_tracker = Some(Box::new(bsv_rs::transaction::MockChainTracker::new(
6858 800_001,
6859 )));
6860 engine
6861 .rerequest_held_proofs(std::slice::from_ref(&candidate), &progress)
6862 .await;
6863 assert_eq!(progress.get().requests, 2, "ask every topic peer");
6864 assert_eq!(progress.get().landed, 0);
6865 assert_eq!(progress.get().refused, 2);
6866 assert!(engine
6867 .lookup_with_txids(&question, None)
6868 .await
6869 .unwrap()
6870 .1
6871 .is_empty());
6872 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
6873 tracker.add_root(800_000, TEST_TXID.into());
6874 engine.chain_tracker = Some(Box::new(tracker));
6875 engine.rerequest_held_proofs(&[candidate], &progress).await;
6876 assert_eq!(progress.get().requests, 4);
6877 assert_eq!(
6878 progress.get().landed,
6879 1,
6880 "count durable proofs, not peer answers"
6881 );
6882 let (_, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
6883 assert_eq!(txids, vec![TEST_TXID]);
6884 let stored = storage
6885 .find_output(TEST_TXID, 0, None, None, true)
6886 .await
6887 .unwrap()
6888 .unwrap();
6889 assert!(
6890 beef_limits::own_proof(stored.beef.as_ref().unwrap(), TEST_TXID)
6891 .unwrap()
6892 .is_some()
6893 );
6894 assert_eq!(storage.proven_at_height(TEST_TXID), Some(Some(800_000)));
6895 }
6896
6897 #[tokio::test]
6898 async fn proof_window_peer_hash_leaf_is_stored_as_own_proof() {
6899 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
6900 let mut engine = make_engine(vec![0]);
6901 let storage = std::rc::Rc::new(MemoryStorage::new());
6902 engine.storage = Box::new(storage.clone());
6903 engine
6904 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
6905 .await
6906 .unwrap();
6907 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
6908 let mut proof =
6909 bsv_rs::transaction::MerklePath::from_hex(&single_leaf_bump_hex(TEST_TXID, 800_000))
6910 .unwrap();
6911 proof.path[0][0].txid = false;
6912 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
6913 tracker.add_root(800_000, proof.compute_root(Some(TEST_TXID)).unwrap());
6914 engine.chain_tracker = Some(Box::new(tracker));
6915 let (_, mut remote) = budget_page_engine(1);
6916 remote.node_cost_ms = 0;
6917 remote.nodes = std::rc::Rc::new(HashMap::from([(
6918 TEST_TXID.into(),
6919 crate::types::GASPNode {
6920 graph_id: String::new(),
6921 raw_tx: String::new(),
6922 output_index: 0,
6923 proof: Some(proof.to_hex()),
6924 tx_metadata: None,
6925 output_metadata: None,
6926 inputs: None,
6927 },
6928 )]));
6929 engine.config.sync_configuration.insert(
6930 "tm_test".into(),
6931 SyncTarget::Peers(vec!["https://peer.example".into()]),
6932 );
6933 engine.set_gasp_remote_factory(Box::new(remote));
6934 let progress = std::cell::Cell::new(PeerProofSummary::default());
6935 engine
6936 .rerequest_held_proofs(
6937 &[HeldOutput {
6938 txid: TEST_TXID.into(),
6939 output_index: 0,
6940 topic: "tm_test".into(),
6941 }],
6942 &progress,
6943 )
6944 .await;
6945 assert_eq!(progress.get().landed, 1);
6946 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6947 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
6948 assert_eq!(txids, vec![TEST_TXID]);
6949 let LookupAnswer::OutputList { outputs } = answer else {
6950 panic!("expected outputs")
6951 };
6952 assert!(
6953 beef_limits::own_proof(&outputs[0].beef, TEST_TXID)
6954 .unwrap()
6955 .is_some(),
6956 "a proven flag must have the child's own path in the served BEEF"
6957 );
6958 }
6959
6960 #[tokio::test]
6961 async fn proof_window_silent_peers_yield_at_the_request_budget() {
6962 use crate::proof_window::{HeldOutput, PeerProofSummary, PEER_PROOF_REQUEST_MS};
6963 let (mut engine, remote) = budget_page_engine(1);
6964 engine.config.sync_configuration.insert(
6965 "tm_test".into(),
6966 SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
6967 );
6968 let elapsed = remote.elapsed.clone();
6969 engine.set_peer_sync_budget(
6970 std::rc::Rc::new(move |ms| {
6971 assert_eq!(ms, PEER_PROOF_REQUEST_MS);
6972 let deadline = elapsed.get() + ms;
6973 let elapsed = elapsed.clone();
6974 Box::pin(std::future::poll_fn(move |_| {
6975 if elapsed.get() >= deadline {
6976 std::task::Poll::Ready(())
6977 } else {
6978 std::task::Poll::Pending
6979 }
6980 }))
6981 }),
6982 30_000,
6983 );
6984 let candidate = HeldOutput {
6985 txid: remote.outputs[0].txid.clone(),
6986 output_index: 0,
6987 topic: "tm_test".into(),
6988 };
6989 engine.set_gasp_remote_factory(Box::new(remote));
6990 let progress = std::cell::Cell::new(PeerProofSummary::default());
6991 engine.rerequest_held_proofs(&[candidate], &progress).await;
6992 assert_eq!(progress.get().requests, 2);
6993 assert_eq!(progress.get().faults, 2);
6994 assert_eq!(progress.get().landed, 0);
6995 }
6996
6997 #[test]
6999 fn sync_limit_clamp() {
7000 assert_eq!(
7001 Engine::clamp_sync_limit(None),
7002 Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
7003 "absent limit gets the default page, never unbounded"
7004 );
7005 assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
7006 assert_eq!(
7007 Engine::clamp_sync_limit(Some(u64::MAX)),
7008 Engine::SYNC_RESPONSE_MAX_LIMIT,
7009 "a huge caller-supplied limit is capped"
7010 );
7011 }
7012
7013 #[tokio::test]
7014 async fn test_lookup_unknown_service_errors() {
7015 let engine = make_engine(vec![]);
7016 let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
7017
7018 let result = engine.lookup(&question, None).await;
7019 assert!(result.is_err());
7020 assert!(matches!(
7021 result.unwrap_err(),
7022 EngineError::LookupServiceNotFound(_)
7023 ));
7024 }
7025
7026 #[tokio::test]
7027 async fn test_list_topic_managers() {
7028 let engine = make_engine(vec![]);
7029 let managers = engine.list_topic_managers().await;
7030
7031 assert_eq!(managers.len(), 1);
7032 assert!(managers.contains_key("tm_test"));
7033 assert_eq!(managers["tm_test"].name, "mock-tm");
7034 }
7035
7036 #[tokio::test]
7037 async fn test_list_lookup_service_providers() {
7038 let engine = make_engine(vec![]);
7039 let services = engine.list_lookup_service_providers().await;
7040
7041 assert_eq!(services.len(), 1);
7042 assert!(services.contains_key("ls_test"));
7043 assert_eq!(services["ls_test"].name, "mock-ls");
7044 }
7045
7046 #[tokio::test]
7047 async fn test_get_documentation() {
7048 let engine = make_engine(vec![]);
7049
7050 let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
7051 assert_eq!(tm_docs, "Mock topic manager");
7052
7053 let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
7054 assert_eq!(ls_docs, "Mock lookup service");
7055
7056 let missing = engine
7057 .get_documentation_for_topic_manager("tm_missing")
7058 .await;
7059 assert_eq!(missing, "No documentation found!");
7060 }
7061
7062 #[tokio::test]
7063 async fn test_provide_foreign_sync_response() {
7064 let engine = make_engine(vec![0]);
7065
7066 let beef = test_tagged_beef(vec!["tm_test"]);
7068 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7069
7070 let request = GASPInitialRequest {
7072 version: 1,
7073 since: 0,
7074 limit: Some(100),
7075 };
7076 let response = engine
7077 .provide_foreign_sync_response(&request, "tm_test")
7078 .await
7079 .unwrap();
7080
7081 assert_eq!(response.utxo_list.len(), 1);
7083 assert_eq!(response.utxo_list[0].txid, TEST_TXID);
7084 assert_eq!(response.since, 0);
7085 }
7086
7087 #[tokio::test]
7088 async fn test_delete_utxo_deep_simple() {
7089 let engine = make_engine(vec![0]);
7090 let beef = test_tagged_beef(vec!["tm_test"]);
7091
7092 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7093
7094 let txid = TEST_TXID;
7095 let output = engine
7096 .storage()
7097 .find_output(txid, 0, Some("tm_test"), None, false)
7098 .await
7099 .unwrap()
7100 .unwrap();
7101
7102 engine.delete_utxo_deep(&output, None).await.unwrap();
7104
7105 let found = engine
7107 .storage()
7108 .find_output(txid, 0, Some("tm_test"), None, false)
7109 .await
7110 .unwrap();
7111 assert!(found.is_none());
7112 }
7113
7114 #[tokio::test]
7115 async fn test_sync_config_defaults_to_ship() {
7116 let engine = make_engine(vec![]);
7117 let sync = &engine.config.sync_configuration;
7118
7119 assert!(sync.contains_key("tm_test"));
7121 assert!(matches!(sync["tm_test"], SyncTarget::Ship));
7122 }
7123
7124 #[tokio::test]
7125 async fn test_multiple_topics_in_single_submit() {
7126 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7127 managers.insert(
7128 "tm_alpha".to_string(),
7129 Box::new(MockTopicManager::admitting(vec![0])),
7130 );
7131 managers.insert(
7132 "tm_beta".to_string(),
7133 Box::new(MockTopicManager::admitting(vec![1])),
7134 );
7135
7136 let engine = Engine::new(
7137 managers,
7138 HashMap::new(),
7139 Box::new(MemoryStorage::new()),
7140 None,
7141 EngineConfig::default(),
7142 );
7143
7144 let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
7145
7146 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7147
7148 assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
7149 assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
7150 }
7151
7152 #[tokio::test]
7153 async fn test_evict_output_with_topic() {
7154 let engine = make_engine(vec![0]);
7155 let beef = test_tagged_beef(vec!["tm_test"]);
7156 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7157
7158 let found = engine
7160 .storage()
7161 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7162 .await
7163 .unwrap();
7164 assert!(found.is_some(), "Output should exist before eviction");
7165
7166 engine
7168 .evict_output(TEST_TXID, 0, Some("tm_test"))
7169 .await
7170 .unwrap();
7171
7172 let found = engine
7174 .storage()
7175 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7176 .await
7177 .unwrap();
7178 assert!(found.is_none(), "Output should be gone after eviction");
7179
7180 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7182 let answer = engine.lookup(&question, None).await.unwrap();
7183 match answer {
7184 LookupAnswer::OutputList { outputs } => {
7185 assert!(
7186 outputs.is_empty(),
7187 "Lookup should return empty after eviction"
7188 );
7189 }
7190 _ => panic!("Expected OutputList"),
7191 }
7192 }
7193
7194 #[tokio::test]
7195 async fn test_evict_output_without_topic() {
7196 let engine = make_engine(vec![0]);
7197 let beef = test_tagged_beef(vec!["tm_test"]);
7198 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7199
7200 engine.evict_output(TEST_TXID, 0, None).await.unwrap();
7202
7203 let found = engine
7204 .storage()
7205 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7206 .await
7207 .unwrap();
7208 assert!(
7209 found.is_none(),
7210 "Output should be gone after topic-less eviction"
7211 );
7212 }
7213
7214 #[tokio::test]
7215 async fn test_evict_nonexistent_output_is_ok() {
7216 let engine = make_engine(vec![]);
7217
7218 let result = engine
7220 .evict_output("nonexistent_txid", 99, Some("tm_test"))
7221 .await;
7222 assert!(result.is_ok(), "Evicting nonexistent output should succeed");
7223 }
7224
7225 use crate::broadcaster::Broadcaster;
7228 use std::sync::Arc;
7229
7230 type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
7231
7232 struct MockBroadcaster {
7233 calls: BroadcastCallLog,
7234 }
7235
7236 impl MockBroadcaster {
7237 fn new() -> Self {
7238 Self {
7239 calls: Arc::new(Mutex::new(Vec::new())),
7240 }
7241 }
7242
7243 #[allow(dead_code, reason = "kept for future test extension")]
7244 fn call_count(&self) -> usize {
7245 self.calls.lock().unwrap().len()
7246 }
7247
7248 #[allow(dead_code, reason = "kept for future test extension")]
7249 fn calls(&self) -> Vec<(String, Vec<String>)> {
7250 self.calls.lock().unwrap().clone()
7251 }
7252 }
7253
7254 #[async_trait(?Send)]
7255 impl Broadcaster for MockBroadcaster {
7256 async fn broadcast_to_host(
7257 &self,
7258 host_url: &str,
7259 tagged_beef: &TaggedBEEF,
7260 ) -> Result<(), String> {
7261 self.calls
7262 .lock()
7263 .unwrap()
7264 .push((host_url.to_string(), tagged_beef.topics.clone()));
7265 Ok(())
7266 }
7267 }
7268
7269 struct MockSHIPLookupService {
7275 records: Mutex<HashMap<String, Vec<UTXOReference>>>,
7277 }
7278
7279 impl MockSHIPLookupService {
7280 fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
7281 Self {
7282 records: Mutex::new(records),
7283 }
7284 }
7285 }
7286
7287 #[async_trait(?Send)]
7288 impl LookupServiceTrait for MockSHIPLookupService {
7289 fn admission_mode(&self) -> AdmissionMode {
7290 AdmissionMode::LockingScript
7291 }
7292
7293 fn spend_notification_mode(&self) -> SpendNotificationMode {
7294 SpendNotificationMode::None
7295 }
7296
7297 async fn output_admitted_by_topic(
7298 &self,
7299 _payload: &OutputAdmittedByTopic,
7300 ) -> Result<(), LookupServiceError> {
7301 Ok(())
7302 }
7303
7304 async fn output_evicted(
7305 &self,
7306 _txid: &str,
7307 _output_index: u32,
7308 ) -> Result<(), LookupServiceError> {
7309 Ok(())
7310 }
7311
7312 async fn lookup(
7313 &self,
7314 question: &LookupQuestion,
7315 ) -> Result<LookupResult, LookupServiceError> {
7316 if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
7318 let records = self.records.lock().unwrap();
7319 let mut results = Vec::new();
7320 for topic_val in topics {
7321 if let Some(topic) = topic_val.as_str() {
7322 if let Some(refs) = records.get(topic) {
7323 results.extend(refs.iter().cloned());
7324 }
7325 }
7326 }
7327 return Ok(LookupResult::OutputList(results));
7328 }
7329 Ok(LookupResult::OutputList(vec![]))
7330 }
7331
7332 async fn get_documentation(&self) -> String {
7333 "Mock SHIP lookup service".to_string()
7334 }
7335
7336 async fn get_metadata(&self) -> ServiceMetadata {
7337 ServiceMetadata {
7338 name: "mock-ls-ship".to_string(),
7339 ..Default::default()
7340 }
7341 }
7342 }
7343
7344 fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
7346 use bsv_rs::script::templates::PushDrop;
7347 use bsv_rs::PublicKey;
7348
7349 let fields = vec![
7350 b"SHIP".to_vec(),
7351 vec![0x02; 33], domain.as_bytes().to_vec(),
7353 topic.as_bytes().to_vec(),
7354 ];
7355
7356 let pubkey = PublicKey::from_hex(
7359 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7360 )
7361 .expect("valid test pubkey");
7362
7363 let pd = PushDrop::new(pubkey, fields);
7364 pd.lock().to_binary()
7365 }
7366
7367 async fn make_engine_with_broadcaster(
7370 admit_indices: Vec<u32>,
7371 broadcaster: MockBroadcaster,
7372 ship_domains: Vec<(&str, &str)>,
7373 hosting_url: Option<&str>,
7374 ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
7375 let calls = broadcaster.calls.clone();
7376
7377 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7378 managers.insert(
7379 "tm_test".to_string(),
7380 Box::new(MockTopicManager::admitting(admit_indices)),
7381 );
7382
7383 let storage = Box::new(MemoryStorage::new());
7384
7385 let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
7389
7390 for (domain, topic) in &ship_domains {
7391 let fake_txid = format!("{:064x}", {
7393 let mut h: u64 = 0;
7394 for b in domain.bytes().chain(topic.bytes()) {
7395 h = h.wrapping_mul(31).wrapping_add(u64::from(b));
7396 }
7397 h
7398 });
7399
7400 ship_records
7402 .entry(topic.to_string())
7403 .or_default()
7404 .push(UTXOReference {
7405 txid: fake_txid.clone(),
7406 output_index: 0,
7407 });
7408
7409 let script = build_ship_pushdrop_script(domain, topic);
7411
7412 let output = Output {
7413 txid: fake_txid,
7414 output_index: 0,
7415 output_script: script,
7416 satoshis: 1,
7417 topic: "tm_ship".to_string(),
7418 spent: false,
7419 outputs_consumed: vec![],
7420 consumed_by: vec![],
7421 beef: None,
7422 block_height: None,
7423 score: None,
7424 };
7425
7426 storage.insert_output(&output).await.unwrap();
7427 }
7428
7429 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7430 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7431 lookup_services.insert(
7432 "ls_ship".to_string(),
7433 Box::new(MockSHIPLookupService::with_records(ship_records)),
7434 );
7435
7436 let config = EngineConfig {
7437 hosting_url: hosting_url.map(str::to_string),
7438 ..Default::default()
7439 };
7440
7441 let engine = Engine::with_chain_tracker(
7442 managers,
7443 lookup_services,
7444 storage,
7445 None,
7446 Some(Box::new(broadcaster)),
7447 None,
7448 config,
7449 );
7450
7451 (engine, calls)
7452 }
7453
7454 #[tokio::test]
7457 async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
7458 let broadcaster = MockBroadcaster::new();
7459 let (engine, calls) = make_engine_with_broadcaster(
7460 vec![0],
7461 broadcaster,
7462 vec![("https://peer1.example.com", "tm_test")],
7463 Some("https://self.example.com"),
7464 )
7465 .await;
7466
7467 let beef = test_tagged_beef(vec!["tm_test"]);
7468 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7469
7470 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7472
7473 let recorded = calls.lock().unwrap();
7475 assert_eq!(
7476 recorded.len(),
7477 1,
7478 "Broadcaster should be called once for the one SHIP peer"
7479 );
7480 assert_eq!(recorded[0].0, "https://peer1.example.com");
7481 }
7482
7483 #[tokio::test]
7484 async fn test_broadcaster_not_called_on_historical_tx() {
7485 let broadcaster = MockBroadcaster::new();
7486 let (engine, calls) = make_engine_with_broadcaster(
7487 vec![0],
7488 broadcaster,
7489 vec![("https://peer1.example.com", "tm_test")],
7490 None,
7491 )
7492 .await;
7493
7494 let beef = test_tagged_beef(vec!["tm_test"]);
7495 let steak = engine
7496 .submit(&beef, SubmitMode::HistoricalTx)
7497 .await
7498 .unwrap();
7499
7500 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7501
7502 assert_eq!(
7504 calls.lock().unwrap().len(),
7505 0,
7506 "Broadcaster should not be called for historical TX"
7507 );
7508 }
7509
7510 #[tokio::test]
7511 async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
7512 let broadcaster = MockBroadcaster::new();
7513 let (engine, calls) = make_engine_with_broadcaster(
7514 vec![0],
7515 broadcaster,
7516 vec![("https://peer1.example.com", "tm_test")],
7517 None,
7518 )
7519 .await;
7520
7521 let beef = test_tagged_beef(vec!["tm_test"]);
7522 let steak = engine
7523 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
7524 .await
7525 .unwrap();
7526
7527 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7528
7529 assert_eq!(
7530 calls.lock().unwrap().len(),
7531 0,
7532 "Broadcaster should not be called for historical-tx-no-spv"
7533 );
7534 }
7535
7536 #[tokio::test]
7537 async fn test_broadcaster_skips_self_hosting_url() {
7538 let broadcaster = MockBroadcaster::new();
7539 let (engine, calls) = make_engine_with_broadcaster(
7540 vec![0],
7541 broadcaster,
7542 vec![
7543 ("https://self.example.com", "tm_test"),
7544 ("https://peer2.example.com", "tm_test"),
7545 ],
7546 Some("https://self.example.com"),
7547 )
7548 .await;
7549
7550 let beef = test_tagged_beef(vec!["tm_test"]);
7551 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7552
7553 let recorded = calls.lock().unwrap();
7555 assert_eq!(
7556 recorded.len(),
7557 1,
7558 "Should broadcast to peer2 only, skipping self"
7559 );
7560 assert_eq!(recorded[0].0, "https://peer2.example.com");
7561 }
7562
7563 #[tokio::test]
7564 async fn test_broadcaster_not_called_when_no_outputs_admitted() {
7565 let broadcaster = MockBroadcaster::new();
7566 let (engine, calls) = make_engine_with_broadcaster(
7568 vec![],
7569 broadcaster,
7570 vec![("https://peer1.example.com", "tm_test")],
7571 None,
7572 )
7573 .await;
7574
7575 let beef = test_tagged_beef(vec!["tm_test"]);
7576 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7577
7578 assert!(steak["tm_test"].outputs_to_admit.is_empty());
7579
7580 assert_eq!(
7582 calls.lock().unwrap().len(),
7583 0,
7584 "Broadcaster should not be called when no outputs are admitted"
7585 );
7586 }
7587
7588 #[tokio::test]
7589 async fn test_broadcaster_not_called_when_no_ship_peers() {
7590 let broadcaster = MockBroadcaster::new();
7591 let (engine, calls) = make_engine_with_broadcaster(
7593 vec![0],
7594 broadcaster,
7595 vec![], None,
7597 )
7598 .await;
7599
7600 let beef = test_tagged_beef(vec!["tm_test"]);
7601 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7602
7603 assert_eq!(
7604 calls.lock().unwrap().len(),
7605 0,
7606 "Broadcaster should not be called when no SHIP peers exist"
7607 );
7608 }
7609
7610 struct SpendModeLookupService {
7616 mode: SpendNotificationMode,
7617 records: Mutex<Vec<UTXOReference>>,
7618 spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
7619 }
7620
7621 impl SpendModeLookupService {
7622 fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
7623 Self {
7624 mode,
7625 records: Mutex::new(Vec::new()),
7626 spent_payloads: capture,
7627 }
7628 }
7629 }
7630
7631 #[async_trait(?Send)]
7632 impl LookupServiceTrait for SpendModeLookupService {
7633 fn admission_mode(&self) -> AdmissionMode {
7634 AdmissionMode::LockingScript
7635 }
7636
7637 fn spend_notification_mode(&self) -> SpendNotificationMode {
7638 self.mode
7639 }
7640
7641 async fn output_admitted_by_topic(
7642 &self,
7643 payload: &OutputAdmittedByTopic,
7644 ) -> Result<(), LookupServiceError> {
7645 let (txid, oi) = match payload {
7646 OutputAdmittedByTopic::LockingScript {
7647 txid, output_index, ..
7648 } => (txid.clone(), *output_index),
7649 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
7650 ("whole".into(), *output_index)
7651 }
7652 };
7653 self.records.lock().unwrap().push(UTXOReference {
7654 txid,
7655 output_index: oi,
7656 });
7657 Ok(())
7658 }
7659
7660 async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
7661 self.spent_payloads.lock().unwrap().push(payload.clone());
7662 Ok(())
7663 }
7664
7665 async fn output_evicted(
7666 &self,
7667 txid: &str,
7668 output_index: u32,
7669 ) -> Result<(), LookupServiceError> {
7670 self.records
7671 .lock()
7672 .unwrap()
7673 .retain(|r| !(r.txid == txid && r.output_index == output_index));
7674 Ok(())
7675 }
7676
7677 async fn lookup(
7678 &self,
7679 _question: &LookupQuestion,
7680 ) -> Result<LookupResult, LookupServiceError> {
7681 Ok(LookupResult::OutputList(
7682 self.records.lock().unwrap().clone(),
7683 ))
7684 }
7685
7686 async fn get_documentation(&self) -> String {
7687 "SpendMode mock lookup service".to_string()
7688 }
7689
7690 async fn get_metadata(&self) -> ServiceMetadata {
7691 ServiceMetadata {
7692 name: "spend-mode-ls".to_string(),
7693 ..Default::default()
7694 }
7695 }
7696 }
7697
7698 const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
7700
7701 async fn make_spend_mode_engine(
7704 mode: SpendNotificationMode,
7705 ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
7706 let storage = MemoryStorage::new();
7707
7708 let prev_output = Output {
7710 txid: PREVIOUS_TXID.to_string(),
7711 output_index: 0,
7712 output_script: vec![0x76, 0xa9],
7713 satoshis: 26174,
7714 topic: "tm_test".to_string(),
7715 spent: false,
7716 outputs_consumed: vec![],
7717 consumed_by: vec![],
7718 beef: Some(test_beef()),
7719 block_height: None,
7720 score: Some(1000.0),
7721 };
7722 storage.insert_output(&prev_output).await.unwrap();
7723
7724 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7725 managers.insert(
7726 "tm_test".to_string(),
7727 Box::new(MockTopicManager::admitting(vec![0])),
7728 );
7729
7730 let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
7731 let ls = SpendModeLookupService::with_mode(mode, capture.clone());
7732 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7733 lookup_services.insert("ls_test".to_string(), Box::new(ls));
7734
7735 let engine = Engine::new(
7736 managers,
7737 lookup_services,
7738 Box::new(storage),
7739 None,
7740 EngineConfig::default(),
7741 );
7742
7743 (engine, capture)
7744 }
7745
7746 #[tokio::test]
7747 async fn test_spend_notification_mode_script() {
7748 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
7749
7750 let beef = test_tagged_beef(vec!["tm_test"]);
7751 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7752
7753 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7755
7756 let payloads = capture.lock().unwrap().clone();
7758 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7759
7760 match &payloads[0] {
7761 OutputSpent::Script {
7762 txid,
7763 output_index,
7764 topic,
7765 spending_txid,
7766 input_index,
7767 unlocking_script,
7768 sequence_number,
7769 ..
7770 } => {
7771 assert_eq!(txid, PREVIOUS_TXID);
7772 assert_eq!(*output_index, 0);
7773 assert_eq!(topic, "tm_test");
7774 assert_eq!(spending_txid, TEST_TXID);
7775 assert_eq!(*input_index, 0);
7777 assert!(
7779 !unlocking_script.is_empty(),
7780 "Unlocking script should be non-empty"
7781 );
7782 assert_eq!(*sequence_number, 0xffff_ffff);
7784 }
7785 other => panic!("Expected OutputSpent::Script, got: {other:?}"),
7786 }
7787 }
7788
7789 #[tokio::test]
7790 async fn test_spend_notification_mode_whole_tx() {
7791 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
7792
7793 let beef = test_tagged_beef(vec!["tm_test"]);
7794 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7795
7796 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7797
7798 let payloads = capture.lock().unwrap().clone();
7799 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7800
7801 match &payloads[0] {
7802 OutputSpent::WholeTx {
7803 txid,
7804 output_index,
7805 topic,
7806 spending_atomic_beef,
7807 ..
7808 } => {
7809 assert_eq!(txid, PREVIOUS_TXID);
7810 assert_eq!(*output_index, 0);
7811 assert_eq!(topic, "tm_test");
7812 let mut named =
7815 bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
7816 assert!(named.is_atomic());
7817 let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
7818 assert_eq!(
7819 named.txs.len(),
7820 submitted.txs.len(),
7821 "the whole body, never pruned"
7822 );
7823 let tip = crate::subject::subject_txid_of(&mut named).unwrap();
7824 assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
7825 }
7826 other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
7827 }
7828 }
7829
7830 #[tokio::test]
7831 async fn test_spend_notification_mode_txid() {
7832 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
7833
7834 let beef = test_tagged_beef(vec!["tm_test"]);
7835 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7836
7837 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7838
7839 let payloads = capture.lock().unwrap().clone();
7840 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7841
7842 match &payloads[0] {
7843 OutputSpent::Txid {
7844 txid,
7845 output_index,
7846 topic,
7847 spending_txid,
7848 } => {
7849 assert_eq!(txid, PREVIOUS_TXID);
7850 assert_eq!(*output_index, 0);
7851 assert_eq!(topic, "tm_test");
7852 assert_eq!(spending_txid, TEST_TXID);
7853 }
7854 other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
7855 }
7856 }
7857
7858 #[tokio::test]
7859 async fn test_spend_notification_mode_none() {
7860 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
7861
7862 let beef = test_tagged_beef(vec!["tm_test"]);
7863 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7864
7865 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7866
7867 let payloads = capture.lock().unwrap().clone();
7868 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7869
7870 match &payloads[0] {
7871 OutputSpent::None {
7872 txid,
7873 output_index,
7874 topic,
7875 } => {
7876 assert_eq!(txid, PREVIOUS_TXID);
7877 assert_eq!(*output_index, 0);
7878 assert_eq!(topic, "tm_test");
7879 }
7880 other => panic!("Expected OutputSpent::None, got: {other:?}"),
7881 }
7882 }
7883
7884 #[tokio::test]
7887 async fn test_start_gasp_sync_empty_config() {
7888 let engine = Engine::new(
7889 HashMap::new(),
7890 HashMap::new(),
7891 Box::new(MemoryStorage::new()),
7892 None,
7893 EngineConfig {
7894 sync_configuration: HashMap::new(),
7895 ..Default::default()
7896 },
7897 );
7898
7899 let result = engine.start_gasp_sync().await.unwrap();
7900 assert!(
7901 result.topics_synced.is_empty(),
7902 "No topics should be synced with empty config"
7903 );
7904 }
7905
7906 #[tokio::test]
7907 async fn test_start_gasp_sync_disabled_topic_skipped() {
7908 let mut sync_config: SyncConfiguration = HashMap::new();
7909 sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
7910
7911 let engine = Engine::new(
7912 HashMap::new(),
7913 HashMap::new(),
7914 Box::new(MemoryStorage::new()),
7915 None,
7916 EngineConfig {
7917 sync_configuration: sync_config,
7918 ..Default::default()
7919 },
7920 );
7921
7922 let result = engine.start_gasp_sync().await.unwrap();
7923 assert!(
7924 result.topics_synced.is_empty(),
7925 "Disabled topics should not appear in results"
7926 );
7927 }
7928
7929 #[tokio::test]
7930 async fn test_start_gasp_sync_peers_config() {
7931 let mut sync_config: SyncConfiguration = HashMap::new();
7932 sync_config.insert(
7933 "tm_test".to_string(),
7934 SyncTarget::Peers(vec![
7935 "https://peer1.example.com".to_string(),
7936 "https://peer2.example.com".to_string(),
7937 ]),
7938 );
7939
7940 let engine = Engine::new(
7941 HashMap::new(),
7942 HashMap::new(),
7943 Box::new(MemoryStorage::new()),
7944 None,
7945 EngineConfig {
7946 sync_configuration: sync_config,
7947 ..Default::default()
7948 },
7949 );
7950
7951 let result = engine.start_gasp_sync().await.unwrap();
7952 assert_eq!(result.topics_synced.len(), 1);
7953
7954 let topic_result = &result.topics_synced["tm_test"];
7955 assert_eq!(topic_result.peers.len(), 2);
7956 assert_eq!(topic_result.sync_type, "peers");
7957 assert!(topic_result.errors.is_empty());
7958 }
7959
7960 #[tokio::test]
7961 async fn test_start_gasp_sync_peers_filters_self() {
7962 let mut sync_config: SyncConfiguration = HashMap::new();
7963 sync_config.insert(
7964 "tm_test".to_string(),
7965 SyncTarget::Peers(vec![
7966 "https://self.example.com".to_string(),
7967 "https://peer2.example.com".to_string(),
7968 ]),
7969 );
7970
7971 let engine = Engine::new(
7972 HashMap::new(),
7973 HashMap::new(),
7974 Box::new(MemoryStorage::new()),
7975 None,
7976 EngineConfig {
7977 hosting_url: Some("https://self.example.com".to_string()),
7978 sync_configuration: sync_config,
7979 ..Default::default()
7980 },
7981 );
7982
7983 let result = engine.start_gasp_sync().await.unwrap();
7984 let topic_result = &result.topics_synced["tm_test"];
7985 assert_eq!(
7986 topic_result.peers.len(),
7987 1,
7988 "Self URL should be filtered out"
7989 );
7990 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
7991 }
7992
7993 #[derive(Clone, Default)]
7994 struct MockShipLookupClient {
7995 records: HashMap<(String, String), Vec<OutputListItem>>,
7996 failures: HashSet<String>,
7997 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
7998 }
7999
8000 struct RecordingDiscoverySyncFactory {
8001 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
8002 }
8003
8004 impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
8005 fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8006 self.calls
8007 .borrow_mut()
8008 .push((peer.to_string(), topic.to_string()));
8009 Box::new(MockSyncRemote { utxos: Vec::new() })
8010 }
8011 }
8012
8013 #[async_trait(?Send)]
8014 impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
8015 async fn lookup(
8016 &self,
8017 host: &str,
8018 question: &LookupQuestion,
8019 ) -> Result<Vec<OutputListItem>, String> {
8020 self.calls
8021 .borrow_mut()
8022 .push((host.to_string(), question.clone()));
8023 if self.failures.contains(host) {
8024 return Err("mock tracker unavailable".to_string());
8025 }
8026 Ok(self
8027 .records
8028 .get(&(host.to_string(), question.service.clone()))
8029 .cloned()
8030 .unwrap_or_default())
8031 }
8032 }
8033
8034 fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
8035 use bsv_rs::script::templates::PushDrop;
8036 let pubkey = bsv_rs::PublicKey::from_hex(
8037 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
8038 )
8039 .unwrap();
8040 let script = PushDrop::new(
8041 pubkey,
8042 vec![
8043 protocol.as_bytes().to_vec(),
8044 vec![0x02; 33],
8045 domain.as_bytes().to_vec(),
8046 scope.as_bytes().to_vec(),
8047 ],
8048 )
8049 .lock();
8050 let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
8051 tx.outputs[0].locking_script = script;
8052 tx.merkle_path = None;
8053 tx.invalidate_caches();
8054 let beef = tx.to_atomic_beef(true).unwrap();
8055 let parsed =
8056 beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
8057 .unwrap();
8058 assert_eq!(
8059 parsed.outputs[0].locking_script,
8060 tx.outputs[0].locking_script
8061 );
8062 OutputListItem {
8063 beef,
8064 output_index: 0,
8065 context: None,
8066 }
8067 }
8068
8069 #[tokio::test]
8070 async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
8071 let (mut engine, _) = make_engine_with_broadcaster(
8072 vec![0],
8073 MockBroadcaster::new(),
8074 vec![
8075 ("https://local.example", "tm_test"),
8076 ("https://shared.example/?local", "tm_test"),
8077 ],
8078 Some("https://self.example"),
8079 )
8080 .await;
8081 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8082 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8083 let mut client = MockShipLookupClient::default();
8084 client.records.insert(
8085 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8086 vec![
8087 discovery_output("SHIP", "https://direct.example", "tm_test"),
8088 discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
8089 discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
8090 discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
8091 discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
8092 OutputListItem {
8093 beef: vec![1, 2, 3],
8094 output_index: 0,
8095 context: None,
8096 },
8097 ],
8098 );
8099 client.records.insert(
8100 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8101 vec![
8102 discovery_output("SLAP", "https://lookup.example", "ls_ship"),
8103 discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
8104 discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
8105 discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
8106 discovery_output("SLAP", "https://other-service.example", "ls_other"),
8107 ],
8108 );
8109 client.records.insert(
8110 ("https://lookup.example".to_string(), "ls_ship".to_string()),
8111 vec![
8112 discovery_output("SHIP", "https://indirect.example", "tm_test"),
8113 discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
8114 ],
8115 );
8116 let calls = client.calls.clone();
8117 engine.set_ship_lookup_client(Box::new(client));
8118 let result = engine.start_gasp_sync().await.unwrap();
8119 assert_eq!(
8120 result.topics_synced["tm_test"].peers,
8121 vec![
8122 "https://bootstrap.example",
8123 "https://direct.example",
8124 "https://indirect.example",
8125 "https://local.example",
8126 "https://shared.example",
8127 ]
8128 );
8129 let calls = calls.borrow();
8130 assert_eq!(calls.len(), 3);
8131 for (_, question) in calls.iter() {
8132 let expected = if question.service == "ls_slap" {
8133 serde_json::json!({"service": "ls_ship"})
8134 } else {
8135 serde_json::json!({"topics": ["tm_test"]})
8136 };
8137 assert_eq!(question.query, expected);
8138 }
8139 }
8140
8141 #[tokio::test]
8142 async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
8143 let (mut engine, _) =
8144 make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
8145 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8146 let mut client = MockShipLookupClient::default();
8147 let outputs = (0..9)
8148 .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
8149 .collect();
8150 client.records.insert(
8151 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8152 outputs,
8153 );
8154 engine.set_ship_lookup_client(Box::new(client));
8155 let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8156 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8157 calls: sync_calls.clone(),
8158 }));
8159 let result = engine.start_gasp_sync().await.unwrap();
8160 let topic = &result.topics_synced["tm_test"];
8161 assert_eq!(topic.sync_type, "ship");
8162 assert_eq!(
8163 topic.peers.len(),
8164 9,
8165 "the lookup-host cap must not truncate sync peers"
8166 );
8167 assert!(topic.errors.is_empty());
8168 assert_eq!(sync_calls.borrow().len(), 9);
8169 for peer in &topic.peers {
8170 assert!(sync_calls
8171 .borrow()
8172 .contains(&(peer.clone(), "tm_test".to_string())));
8173 }
8174 }
8175
8176 #[tokio::test]
8177 async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
8178 let mut engine = make_engine(vec![]);
8179 engine.config.slap_trackers = vec![
8180 "https://dead.example".to_string(),
8181 "https://live.example".to_string(),
8182 ];
8183 let mut client = MockShipLookupClient::default();
8184 client.failures.insert("https://dead.example".to_string());
8185 client.records.insert(
8186 ("https://live.example".to_string(), "ls_ship".to_string()),
8187 vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
8188 );
8189 engine.set_ship_lookup_client(Box::new(client));
8190 let result = engine.start_gasp_sync().await.unwrap();
8191 assert_eq!(
8192 result.topics_synced["tm_test"].peers,
8193 vec!["https://peer.example"]
8194 );
8195 }
8196
8197 #[tokio::test]
8198 async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
8199 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
8200 let mut engine = make_engine(vec![]);
8201 engine
8202 .config
8203 .sync_configuration
8204 .insert("tm_other".to_string(), SyncTarget::Ship);
8205 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8206 let mut client = MockShipLookupClient::default();
8207 let mut providers = Vec::new();
8208 for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
8209 let host = format!("https://lookup{n}.example");
8210 providers.push(discovery_output("SLAP", &host, "ls_ship"));
8211 client.records.insert(
8212 (host, "ls_ship".to_string()),
8213 vec![
8214 discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
8215 discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
8216 ],
8217 );
8218 }
8219 client.records.insert(
8220 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8221 providers,
8222 );
8223 let calls = client.calls.clone();
8224 engine.set_ship_lookup_client(Box::new(client));
8225 let result = engine.start_gasp_sync().await.unwrap();
8226 assert_eq!(
8227 result.topics_synced["tm_test"].peers.len(),
8228 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8229 );
8230 assert_eq!(
8231 result.topics_synced["tm_other"].peers.len(),
8232 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8233 );
8234 let calls = calls.borrow();
8235 assert_eq!(
8236 calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
8237 1
8238 );
8239 let providers: HashSet<_> = calls
8240 .iter()
8241 .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
8242 .map(|(h, _)| h)
8243 .collect();
8244 assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
8245 assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
8246 }
8247
8248 #[tokio::test]
8249 async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
8250 let mut engine = make_engine(vec![]);
8251 engine.config.hosting_url = Some("https://self.example".to_string());
8252 engine.config.slap_trackers = vec!["https://dead.example".to_string()];
8253 let mut client = MockShipLookupClient::default();
8254 client.failures.insert("https://dead.example".to_string());
8255 engine.set_ship_lookup_client(Box::new(client));
8256 engine.set_ship_bootstrap_peers(
8257 "tm_test",
8258 vec![
8259 "https://bootstrap.example".to_string(),
8260 "https://BOOTSTRAP.example.:443/?x".to_string(),
8261 "http://SELF.example.:8443/path".to_string(),
8262 ],
8263 );
8264 let result = engine.start_gasp_sync().await.unwrap();
8265 assert_eq!(
8266 result.topics_synced["tm_test"].peers,
8267 vec!["https://bootstrap.example"]
8268 );
8269 }
8270
8271 #[tokio::test]
8272 async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
8273 let mut engine = make_engine(vec![]);
8274 for topic in ["tm_ship", "tm_slap"] {
8275 engine.config.sync_configuration.insert(
8276 topic.to_string(),
8277 SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
8278 );
8279 }
8280 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8281 let mut client = MockShipLookupClient::default();
8282 client.records.insert(
8283 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8284 vec![discovery_output(
8285 "SHIP",
8286 "https://discovered.example",
8287 "tm_test",
8288 )],
8289 );
8290 let lookups = client.calls.clone();
8291 engine.set_ship_lookup_client(Box::new(client));
8292 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8293 let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8294 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8295 calls: calls.clone(),
8296 }));
8297 engine.start_gasp_sync().await.unwrap();
8298 assert_eq!(
8299 calls
8300 .borrow()
8301 .iter()
8302 .map(|(_, topic)| topic.as_str())
8303 .collect::<Vec<_>>(),
8304 vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
8305 );
8306 assert_eq!(lookups.borrow().len(), 2);
8307 }
8308
8309 #[tokio::test]
8310 async fn test_start_gasp_sync_ship_discovers_peers() {
8311 let broadcaster = MockBroadcaster::new();
8312 let (engine, _calls) = make_engine_with_broadcaster(
8313 vec![0],
8314 broadcaster,
8315 vec![
8316 ("https://peer1.example.com", "tm_test"),
8317 ("https://peer2.example.com", "tm_test"),
8318 ],
8319 Some("https://self.example.com"),
8320 )
8321 .await;
8322
8323 let result = engine.start_gasp_sync().await.unwrap();
8324 assert!(
8325 result.topics_synced.contains_key("tm_test"),
8326 "tm_test should be in sync results"
8327 );
8328
8329 let topic_result = &result.topics_synced["tm_test"];
8330 assert_eq!(topic_result.sync_type, "ship");
8331 assert_eq!(
8332 topic_result.peers.len(),
8333 2,
8334 "Should discover both SHIP peers"
8335 );
8336 assert!(topic_result
8337 .peers
8338 .contains(&"https://peer1.example.com".to_string()));
8339 assert!(topic_result
8340 .peers
8341 .contains(&"https://peer2.example.com".to_string()));
8342 }
8343
8344 #[tokio::test]
8345 async fn test_start_gasp_sync_ship_filters_self() {
8346 let broadcaster = MockBroadcaster::new();
8347 let (engine, _calls) = make_engine_with_broadcaster(
8348 vec![0],
8349 broadcaster,
8350 vec![
8351 ("https://self.example.com", "tm_test"),
8352 ("https://peer2.example.com", "tm_test"),
8353 ],
8354 Some("https://self.example.com"),
8355 )
8356 .await;
8357
8358 let result = engine.start_gasp_sync().await.unwrap();
8359 let topic_result = &result.topics_synced["tm_test"];
8360 assert_eq!(
8361 topic_result.peers.len(),
8362 1,
8363 "Self URL should be filtered out from SHIP discovery"
8364 );
8365 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
8366 }
8367
8368 #[tokio::test]
8369 async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
8370 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8371 managers.insert(
8372 "tm_custom".to_string(),
8373 Box::new(MockTopicManager::admitting(vec![])),
8374 );
8375
8376 let mut sync_config: SyncConfiguration = HashMap::new();
8377 sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
8378
8379 let engine = Engine::new(
8380 managers,
8381 HashMap::new(),
8382 Box::new(MemoryStorage::new()),
8383 None,
8384 EngineConfig {
8385 sync_configuration: sync_config,
8386 ..Default::default()
8387 },
8388 );
8389
8390 let result = engine.start_gasp_sync().await.unwrap();
8391 let topic_result = &result.topics_synced["tm_custom"];
8392 assert!(
8393 topic_result.peers.is_empty(),
8394 "Without ls_ship, SHIP discovery should return no peers"
8395 );
8396 }
8397
8398 #[tokio::test]
8399 async fn test_start_gasp_sync_mixed_config() {
8400 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8401 managers.insert(
8402 "tm_peers".to_string(),
8403 Box::new(MockTopicManager::admitting(vec![])),
8404 );
8405 managers.insert(
8406 "tm_disabled".to_string(),
8407 Box::new(MockTopicManager::admitting(vec![])),
8408 );
8409 managers.insert(
8410 "tm_ship_topic".to_string(),
8411 Box::new(MockTopicManager::admitting(vec![])),
8412 );
8413
8414 let mut sync_config: SyncConfiguration = HashMap::new();
8415 sync_config.insert(
8416 "tm_peers".to_string(),
8417 SyncTarget::Peers(vec!["https://peer.com".to_string()]),
8418 );
8419 sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
8420 sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
8421
8422 let engine = Engine::new(
8423 managers,
8424 HashMap::new(),
8425 Box::new(MemoryStorage::new()),
8426 None,
8427 EngineConfig {
8428 sync_configuration: sync_config,
8429 ..Default::default()
8430 },
8431 );
8432
8433 let result = engine.start_gasp_sync().await.unwrap();
8434 assert_eq!(result.topics_synced.len(), 2);
8435 assert!(result.topics_synced.contains_key("tm_peers"));
8436 assert!(result.topics_synced.contains_key("tm_ship_topic"));
8437 assert!(!result.topics_synced.contains_key("tm_disabled"));
8438
8439 assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
8440 assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
8441 assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
8442 }
8443
8444 #[tokio::test]
8445 async fn test_gasp_sync_result_serialization() {
8446 let mut topics_synced = HashMap::new();
8447 topics_synced.insert(
8448 "tm_test".to_string(),
8449 super::TopicSyncResult {
8450 roots_proven: 0,
8451 roots_unproven: 0,
8452 cursor_held_by: Vec::new(),
8453 expired_refused: 0,
8454 peers: vec!["https://peer.com".to_string()],
8455 sync_type: "ship".to_string(),
8456 errors: vec![],
8457 pruned_inputs: 0,
8458 discarded_graphs: 0,
8459 anchor_walk_held_graphs: 0,
8460 finalized_graphs: 0,
8461 deadline_dropped_graphs: 0,
8462 cursor_moves: Vec::new(),
8463 deferred_graphs: 0,
8464 resumed_graphs: 0,
8465 converged_graphs: 0,
8466 dropped_graphs: Vec::new(),
8467 stalled_graphs: 0,
8468 held_back_graphs: 0,
8469 },
8470 );
8471
8472 let result = super::GASPSyncResult { topics_synced };
8473 let json = serde_json::to_string(&result).unwrap();
8474 let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
8475
8476 assert_eq!(back.topics_synced.len(), 1);
8477 assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
8478 assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
8479 }
8480
8481 struct MockSyncRemote {
8485 utxos: Vec<crate::types::GASPOutput>,
8486 }
8487
8488 #[async_trait(?Send)]
8489 impl crate::gasp::GASPRemote for MockSyncRemote {
8490 async fn get_initial_response(
8491 &self,
8492 request: &crate::types::GASPInitialRequest,
8493 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
8494 let utxos: Vec<crate::types::GASPOutput> = self
8495 .utxos
8496 .iter()
8497 .filter(|u| u.score as u64 >= request.since)
8498 .cloned()
8499 .collect();
8500 Ok(crate::types::GASPInitialResponse {
8501 utxo_list: utxos,
8502 since: request.since,
8503 })
8504 }
8505 async fn get_initial_reply(
8506 &self,
8507 _: &crate::types::GASPInitialResponse,
8508 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
8509 Ok(crate::types::GASPInitialReply {
8510 utxo_list: Vec::new(),
8511 })
8512 }
8513 async fn request_node(
8514 &self,
8515 graph_id: &str,
8516 txid: &str,
8517 output_index: u32,
8518 _: bool,
8519 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
8520 Ok(crate::types::GASPNode {
8521 graph_id: graph_id.to_string(),
8522 raw_tx: format!("rawtx_{txid}"),
8523 output_index,
8524 proof: None,
8525 tx_metadata: None,
8526 output_metadata: None,
8527 inputs: None,
8528 })
8529 }
8530 async fn submit_node(
8531 &self,
8532 _: &crate::types::GASPNode,
8533 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
8534 Ok(None)
8535 }
8536 }
8537
8538 struct MockGASPRemoteFactory;
8540
8541 #[derive(Clone)]
8542 struct BudgetPageRemote {
8543 outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
8544 nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
8545 elapsed: std::rc::Rc<Cell<u64>>,
8546 requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
8547 clamp: Option<usize>,
8548 node_cost_ms: u64,
8549 }
8550
8551 #[async_trait(?Send)]
8552 impl crate::gasp::GASPRemote for BudgetPageRemote {
8553 async fn get_initial_response(
8554 &self,
8555 request: &crate::types::GASPInitialRequest,
8556 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
8557 self.requests.borrow_mut().push(request.clone());
8558 let page = self
8559 .outputs
8560 .iter()
8561 .filter(|u| u.score as u64 >= request.since);
8562 let limit = self.clamp.map_or(usize::MAX, |cap| {
8563 cap.min(request.limit.unwrap_or(u64::MAX) as usize)
8564 });
8565 Ok(crate::types::GASPInitialResponse {
8566 utxo_list: page.take(limit).cloned().collect(),
8567 since: request.since,
8568 })
8569 }
8570
8571 async fn get_initial_reply(
8572 &self,
8573 _: &crate::types::GASPInitialResponse,
8574 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
8575 unreachable!()
8576 }
8577
8578 async fn request_node(
8579 &self,
8580 graph_id: &str,
8581 txid: &str,
8582 _: u32,
8583 _: bool,
8584 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
8585 self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
8586 if self.node_cost_ms > 0 {
8587 let mut yielded = false;
8588 std::future::poll_fn(|cx| {
8589 if yielded {
8590 std::task::Poll::Ready(())
8591 } else {
8592 yielded = true;
8593 cx.waker().wake_by_ref();
8594 std::task::Poll::Pending
8595 }
8596 })
8597 .await;
8598 }
8599 let mut node = self
8600 .nodes
8601 .get(txid)
8602 .cloned()
8603 .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
8604 node.graph_id = graph_id.to_string();
8605 Ok(node)
8606 }
8607
8608 async fn submit_node(
8609 &self,
8610 _: &crate::types::GASPNode,
8611 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
8612 unreachable!()
8613 }
8614 }
8615
8616 impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
8617 fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
8618 Box::new(self.clone())
8619 }
8620 }
8621
8622 fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
8623 let mut engine = make_engine(vec![0]);
8624 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
8625 let mut outputs = Vec::new();
8626 let mut nodes = HashMap::new();
8627 for n in 1..=count {
8628 let tx = child_of(&format!("{n:064x}"), 0);
8629 let txid = tx.id();
8630 tracker.add_root(n, txid.clone());
8631 outputs.push(crate::types::GASPOutput {
8632 txid: txid.clone(),
8633 output_index: 0,
8634 score: f64::from(n * 10),
8635 });
8636 nodes.insert(
8637 txid.clone(),
8638 crate::types::GASPNode {
8639 graph_id: String::new(),
8640 raw_tx: tx.to_hex(),
8641 output_index: 0,
8642 proof: Some(single_leaf_bump_hex(&txid, n)),
8643 tx_metadata: None,
8644 output_metadata: None,
8645 inputs: None,
8646 },
8647 );
8648 }
8649 engine.chain_tracker = Some(Box::new(tracker));
8650 engine.config.sync_configuration.insert(
8651 "tm_test".to_string(),
8652 SyncTarget::Peers(vec!["https://budget.example".to_string()]),
8653 );
8654 let remote = BudgetPageRemote {
8655 outputs: std::rc::Rc::new(outputs),
8656 nodes: std::rc::Rc::new(nodes),
8657 elapsed: std::rc::Rc::new(Cell::new(0)),
8658 requests: std::rc::Rc::default(),
8659 clamp: None,
8660 node_cost_ms: 5000,
8661 };
8662 (engine, remote)
8663 }
8664
8665 fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
8666 let elapsed = remote.elapsed.clone();
8667 engine.set_peer_sync_budget(
8668 std::rc::Rc::new(move |ms| {
8669 assert_eq!(ms, 30_000);
8670 let elapsed = elapsed.clone();
8671 let until = elapsed.get() + ms;
8672 Box::pin(std::future::poll_fn(move |_| {
8673 if elapsed.get() >= until {
8674 std::task::Poll::Ready(())
8675 } else {
8676 std::task::Poll::Pending
8677 }
8678 }))
8679 }),
8680 30_000,
8681 );
8682 }
8683
8684 #[tokio::test]
8685 async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
8686 let (mut engine, remote) = budget_page_engine(3000);
8689 virtual_peer_budget(&mut engine, &remote);
8690 engine.set_gasp_remote_factory(Box::new(remote.clone()));
8691 for round in 1..=2 {
8692 let result = engine.start_gasp_sync().await.unwrap();
8693 let topic = &result.topics_synced["tm_test"];
8694 assert_eq!(topic.finalized_graphs, 5);
8695 assert_eq!(topic.deadline_dropped_graphs, 1);
8696 assert_eq!(topic.cursor_moves.len(), 1);
8697 let expected = round * 50;
8698 assert_eq!(topic.cursor_moves[0].to, expected);
8699 assert_eq!(
8700 engine
8701 .storage
8702 .get_last_interaction("https://budget.example", "tm_test")
8703 .await
8704 .unwrap(),
8705 expected
8706 );
8707 {
8708 let requests = remote.requests.borrow();
8709 assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
8710 }
8711 for output in remote.outputs.iter().take((round * 5) as usize) {
8712 assert!(engine
8713 .storage
8714 .find_output(&output.txid, 0, Some("tm_test"), None, true)
8715 .await
8716 .unwrap()
8717 .is_some());
8718 }
8719 let unfinished = &remote.outputs[(round * 5) as usize];
8720 assert!(engine
8721 .storage
8722 .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
8723 .await
8724 .unwrap()
8725 .is_none());
8726 }
8727 }
8728
8729 #[tokio::test]
8730 async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
8731 let (mut engine, mut remote) = budget_page_engine(7);
8732 remote.clamp = Some(3);
8733 remote.node_cost_ms = 0;
8734 virtual_peer_budget(&mut engine, &remote);
8735 engine.set_gasp_remote_factory(Box::new(remote.clone()));
8736 let result = engine.start_gasp_sync().await.unwrap();
8737 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
8738 let requests = remote.requests.borrow();
8739 assert_eq!(requests.len(), 4);
8740 assert!(requests.iter().all(|request| request.limit == Some(100)));
8741 assert_eq!(
8742 requests.iter().map(|r| r.since).collect::<Vec<_>>(),
8743 vec![0, 30, 50, 70]
8744 );
8745 }
8746
8747 #[tokio::test]
8748 async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
8749 let (mut engine, mut remote) = budget_page_engine(3000);
8750 std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
8751 virtual_peer_budget(&mut engine, &remote);
8752 engine.set_gasp_remote_factory(Box::new(remote));
8753 let result = engine.start_gasp_sync().await.unwrap();
8754 let topic = &result.topics_synced["tm_test"];
8755 assert_eq!(topic.finalized_graphs, 5);
8756 assert!(topic.cursor_moves.is_empty());
8759 }
8760
8761 #[tokio::test]
8762 async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
8763 let (mut engine, mut remote) = budget_page_engine(3);
8764 std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
8765 remote.node_cost_ms = 10_000;
8766 virtual_peer_budget(&mut engine, &remote);
8767 engine.set_gasp_remote_factory(Box::new(remote));
8768 let result = engine.start_gasp_sync().await.unwrap();
8769 let topic = &result.topics_synced["tm_test"];
8770 assert_eq!(topic.finalized_graphs, 2);
8771 assert_eq!(topic.cursor_moves[0].to, 10);
8772 }
8773
8774 #[tokio::test]
8775 async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
8776 let (mut engine, mut remote) = budget_page_engine(3000);
8777 let failed = remote.outputs[2].txid.clone();
8778 std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
8779 virtual_peer_budget(&mut engine, &remote);
8780 engine.set_gasp_remote_factory(Box::new(remote));
8781 let result = engine.start_gasp_sync().await.unwrap();
8782 let topic = &result.topics_synced["tm_test"];
8783 assert_eq!(topic.finalized_graphs, 4);
8784 assert_eq!(topic.cursor_moves[0].to, 20);
8785 }
8786
8787 struct ProofOnlyRootFetcher {
8788 proofs: HashMap<String, String>,
8789 requested: std::rc::Rc<Cell<u64>>,
8790 }
8791
8792 #[async_trait(?Send)]
8793 impl AncestorFetcher for ProofOnlyRootFetcher {
8794 async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
8795 assert!(
8796 self.requested.get() > 0,
8797 "root proof request must precede ancestry"
8798 );
8799 Err(GASPError::RemoteError("raw source unavailable".to_string()))
8800 }
8801 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
8802 self.requested.set(self.requested.get() + 1);
8803 self.proofs.get(txid).cloned()
8804 }
8805 }
8806
8807 #[tokio::test]
8808 async fn copy_sync_leaves_completion_courier_untouched() {
8809 let (mut engine, mut remote) = budget_page_engine(1);
8810 remote.node_cost_ms = 0;
8811 let txid = remote.outputs[0].txid.clone();
8812 let proof = std::rc::Rc::make_mut(&mut remote.nodes)
8813 .get_mut(&txid)
8814 .unwrap()
8815 .proof
8816 .take()
8817 .unwrap();
8818 let completion_asks = std::rc::Rc::new(Cell::new(0));
8819 let sync_asks = std::rc::Rc::new(Cell::new(0));
8820 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8821 proofs: HashMap::new(),
8822 requested: completion_asks.clone(),
8823 }));
8824 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8825 proofs: HashMap::from([(txid.clone(), proof.clone())]),
8826 requested: sync_asks.clone(),
8827 }));
8828 engine.set_gasp_remote_factory(Box::new(remote));
8829 let result = engine.start_gasp_sync().await.unwrap();
8830 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
8831 assert_eq!(result.topics_synced["tm_test"].roots_proven, 1);
8832 assert_eq!(result.topics_synced["tm_test"].roots_unproven, 0);
8833 let stored = engine
8834 .storage()
8835 .find_output(&txid, 0, Some("tm_test"), None, true)
8836 .await
8837 .unwrap()
8838 .unwrap();
8839 assert!(
8840 crate::beef_limits::own_proof(stored.beef.as_ref().unwrap(), &txid)
8841 .unwrap()
8842 .is_some()
8843 );
8844 assert_eq!(completion_asks.get(), 0);
8845 assert_eq!(sync_asks.get(), 1);
8846 let served = engine
8847 .provide_foreign_gasp_node(&format!("{txid}.0"), &txid, 0)
8848 .await
8849 .unwrap();
8850 assert_eq!(served.proof, Some(proof));
8851 }
8852
8853 #[tokio::test]
8854 async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
8855 for proven in [true, false] {
8856 let (mut engine, mut remote) = budget_page_engine(1);
8857 let txid = remote.outputs[0].txid.clone();
8858 let proof = remote.nodes[&txid].proof.clone().unwrap();
8859 std::rc::Rc::make_mut(&mut remote.nodes)
8860 .get_mut(&txid)
8861 .unwrap()
8862 .proof = None;
8863 let requested = std::rc::Rc::new(Cell::new(0));
8864 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8865 proofs: if proven {
8866 HashMap::from([(txid.clone(), proof)])
8867 } else {
8868 HashMap::new()
8869 },
8870 requested: requested.clone(),
8871 }));
8872 virtual_peer_budget(&mut engine, &remote);
8873 engine.set_gasp_remote_factory(Box::new(remote));
8874 let result = engine.start_gasp_sync().await.unwrap();
8875 assert!(requested.get() > 0);
8876 assert_eq!(
8877 result.topics_synced["tm_test"].finalized_graphs,
8878 u64::from(proven)
8879 );
8880 assert_eq!(
8881 engine
8882 .storage
8883 .find_output(&txid, 0, Some("tm_test"), None, true)
8884 .await
8885 .unwrap()
8886 .is_some(),
8887 proven
8888 );
8889 if !proven {
8890 assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
8891 }
8892 }
8893 }
8894
8895 #[tokio::test]
8896 async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
8897 let (mut engine, mut remote) = budget_page_engine(1);
8898 let txid = remote.outputs[0].txid.clone();
8899 let proof = remote.nodes[&txid].proof.clone().unwrap();
8900 std::rc::Rc::make_mut(&mut remote.nodes)
8901 .get_mut(&txid)
8902 .unwrap()
8903 .proof = None;
8904 let requested = std::rc::Rc::new(Cell::new(0));
8905 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8906 proofs: HashMap::new(),
8907 requested: requested.clone(),
8908 }));
8909 virtual_peer_budget(&mut engine, &remote);
8910 engine.set_graph_budget(
8911 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
8912 5,
8913 5000,
8914 );
8915 engine.set_gasp_remote_factory(Box::new(remote));
8916 let held = engine.start_gasp_sync().await.unwrap();
8917 assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
8918 assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
8919 assert_eq!(held.topics_synced["tm_test"].cursor_moves[0].to, 10);
8920 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8921 proofs: HashMap::from([(txid.clone(), proof)]),
8922 requested,
8923 }));
8924 engine
8925 .storage()
8926 .update_last_interaction("https://budget.example", "tm_test", 20)
8927 .await
8928 .unwrap();
8929 let landed = engine.start_gasp_sync().await.unwrap();
8930 assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
8931 assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
8932 assert!(landed.topics_synced["tm_test"].cursor_moves.is_empty());
8933 assert!(engine
8934 .storage
8935 .find_output(&txid, 0, Some("tm_test"), None, true)
8936 .await
8937 .unwrap()
8938 .is_some());
8939 }
8940
8941 #[tokio::test]
8942 async fn proofs_deadline_stalls_become_durable_after_three_runs() {
8943 let (mut engine, remote) = budget_page_engine(1);
8944 let elapsed = remote.elapsed.clone();
8945 let root = format!("{}.0", remote.outputs[0].txid);
8946 engine.set_graph_budget(
8947 std::rc::Rc::new(move |ms| {
8948 let until = elapsed.get() + ms;
8949 let elapsed = elapsed.clone();
8950 Box::pin(std::future::poll_fn(move |_| {
8951 if elapsed.get() >= until {
8952 std::task::Poll::Ready(())
8953 } else {
8954 std::task::Poll::Pending
8955 }
8956 }))
8957 }),
8958 10,
8959 1_000,
8960 );
8961 engine.set_gasp_remote_factory(Box::new(remote));
8962 for round in 1..=crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
8963 let result = engine.start_gasp_sync().await.unwrap();
8964 let topic = &result.topics_synced["tm_test"];
8965 if round < crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
8966 assert!(topic.cursor_moves.is_empty());
8967 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
8968 } else {
8969 assert_eq!(topic.cursor_moves[0].to, 10);
8970 assert_eq!(topic.deferred_graphs, 1);
8971 }
8972 }
8973 let held = engine
8974 .storage()
8975 .get_deferred_graph("https://budget.example", "tm_test", &root)
8976 .await
8977 .unwrap()
8978 .unwrap();
8979 assert!(held.cursor_passable);
8980 assert!(held.nodes.is_empty());
8981 }
8982
8983 #[tokio::test]
8984 async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
8985 let (mut engine, mut remote) = budget_page_engine(1);
8986 let txid = remote.outputs[0].txid.clone();
8987 std::rc::Rc::make_mut(&mut remote.nodes)
8988 .get_mut(&txid)
8989 .unwrap()
8990 .proof = None;
8991 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8992 proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
8993 requested: std::rc::Rc::default(),
8994 }));
8995 virtual_peer_budget(&mut engine, &remote);
8996 engine.set_gasp_remote_factory(Box::new(remote));
8997 let result = engine.start_gasp_sync().await.unwrap();
8998 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
8999 assert!(engine
9000 .storage
9001 .find_output(&txid, 0, Some("tm_test"), None, true)
9002 .await
9003 .unwrap()
9004 .is_none());
9005 }
9006
9007 impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
9008 fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
9009 Box::new(MockSyncRemote {
9010 utxos: vec![
9011 crate::types::GASPOutput {
9012 txid: "remote_tx1".to_string(),
9013 output_index: 0,
9014 score: 100.0,
9015 },
9016 crate::types::GASPOutput {
9017 txid: "remote_tx2".to_string(),
9018 output_index: 0,
9019 score: 200.0,
9020 },
9021 ],
9022 })
9023 }
9024 }
9025
9026 #[tokio::test]
9027 async fn test_start_gasp_sync_with_factory_runs_sync() {
9028 let mut sync_config: SyncConfiguration = HashMap::new();
9029 sync_config.insert(
9030 "tm_test".to_string(),
9031 SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
9032 );
9033
9034 let mut engine = Engine::new(
9035 HashMap::new(),
9036 HashMap::new(),
9037 Box::new(MemoryStorage::new()),
9038 None,
9039 EngineConfig {
9040 sync_configuration: sync_config,
9041 ..Default::default()
9042 },
9043 );
9044
9045 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
9046
9047 let result = engine.start_gasp_sync().await.unwrap();
9048 assert_eq!(result.topics_synced.len(), 1);
9049
9050 let topic_result = &result.topics_synced["tm_test"];
9051 assert_eq!(topic_result.peers.len(), 1);
9052 assert!(
9053 topic_result.errors.is_empty(),
9054 "Sync should succeed with mock remote"
9055 );
9056
9057 let last = engine
9059 .storage()
9060 .get_last_interaction("https://peer1.example.com", "tm_test")
9061 .await
9062 .unwrap();
9063 assert_eq!(
9066 last, 0,
9067 "cursor must remain at entry when no output completed"
9068 );
9069 }
9070
9071 #[tokio::test]
9072 async fn test_start_gasp_sync_with_factory_handles_error() {
9073 struct FailingFactory;
9074
9075 impl crate::gasp::GASPRemoteFactory for FailingFactory {
9076 fn create_remote(
9077 &self,
9078 _peer_url: &str,
9079 _topic: &str,
9080 ) -> Box<dyn crate::gasp::GASPRemote> {
9081 struct FailingRemote;
9082 #[async_trait(?Send)]
9083 impl crate::gasp::GASPRemote for FailingRemote {
9084 async fn get_initial_response(
9085 &self,
9086 _: &crate::types::GASPInitialRequest,
9087 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
9088 {
9089 Err(crate::gasp::GASPError::RemoteError(
9090 "connection refused".into(),
9091 ))
9092 }
9093 async fn get_initial_reply(
9094 &self,
9095 _: &crate::types::GASPInitialResponse,
9096 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
9097 {
9098 unreachable!()
9099 }
9100 async fn request_node(
9101 &self,
9102 _: &str,
9103 _: &str,
9104 _: u32,
9105 _: bool,
9106 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
9107 {
9108 unreachable!()
9109 }
9110 async fn submit_node(
9111 &self,
9112 _: &crate::types::GASPNode,
9113 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
9114 {
9115 unreachable!()
9116 }
9117 }
9118 Box::new(FailingRemote)
9119 }
9120 }
9121
9122 let mut sync_config: SyncConfiguration = HashMap::new();
9123 sync_config.insert(
9124 "tm_test".to_string(),
9125 SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
9126 );
9127
9128 let mut engine = Engine::new(
9129 HashMap::new(),
9130 HashMap::new(),
9131 Box::new(MemoryStorage::new()),
9132 None,
9133 EngineConfig {
9134 sync_configuration: sync_config,
9135 ..Default::default()
9136 },
9137 );
9138
9139 engine.set_gasp_remote_factory(Box::new(FailingFactory));
9140
9141 let result = engine.start_gasp_sync().await.unwrap();
9142 assert_eq!(result.topics_synced.len(), 1);
9143
9144 let topic_result = &result.topics_synced["tm_test"];
9145 assert_eq!(
9146 topic_result.errors.len(),
9147 1,
9148 "Should have one error for the failing peer"
9149 );
9150 assert!(topic_result.errors[0].contains("connection refused"));
9151 }
9152
9153 #[tokio::test]
9154 async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
9155 let mut sync_config: SyncConfiguration = HashMap::new();
9156 sync_config.insert(
9157 "tm_test".to_string(),
9158 SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
9159 );
9160
9161 let engine = Engine::new(
9163 HashMap::new(),
9164 HashMap::new(),
9165 Box::new(MemoryStorage::new()),
9166 None,
9167 EngineConfig {
9168 sync_configuration: sync_config,
9169 ..Default::default()
9170 },
9171 );
9172
9173 let result = engine.start_gasp_sync().await.unwrap();
9174 assert_eq!(result.topics_synced.len(), 1);
9175
9176 let topic_result = &result.topics_synced["tm_test"];
9177 assert_eq!(topic_result.peers.len(), 1);
9178 assert!(topic_result.errors.is_empty());
9179 }
9180
9181 #[tokio::test]
9182 async fn test_start_gasp_sync_with_factory_multiple_peers() {
9183 let mut sync_config: SyncConfiguration = HashMap::new();
9184 sync_config.insert(
9185 "tm_test".to_string(),
9186 SyncTarget::Peers(vec![
9187 "https://peer1.example.com".to_string(),
9188 "https://peer2.example.com".to_string(),
9189 ]),
9190 );
9191
9192 let mut engine = Engine::new(
9193 HashMap::new(),
9194 HashMap::new(),
9195 Box::new(MemoryStorage::new()),
9196 None,
9197 EngineConfig {
9198 sync_configuration: sync_config,
9199 ..Default::default()
9200 },
9201 );
9202
9203 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
9204
9205 let result = engine.start_gasp_sync().await.unwrap();
9206 let topic_result = &result.topics_synced["tm_test"];
9207 assert_eq!(topic_result.peers.len(), 2);
9208 assert!(topic_result.errors.is_empty());
9209
9210 let last1 = engine
9212 .storage()
9213 .get_last_interaction("https://peer1.example.com", "tm_test")
9214 .await
9215 .unwrap();
9216 let last2 = engine
9217 .storage()
9218 .get_last_interaction("https://peer2.example.com", "tm_test")
9219 .await
9220 .unwrap();
9221 assert_eq!(last1, 0);
9222 assert_eq!(last2, 0);
9223 }
9224
9225 struct HangingRemote;
9231
9232 #[async_trait(?Send)]
9233 impl crate::gasp::GASPRemote for HangingRemote {
9234 async fn get_initial_response(
9235 &self,
9236 _: &crate::types::GASPInitialRequest,
9237 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9238 std::future::pending::<()>().await;
9239 unreachable!()
9240 }
9241 async fn get_initial_reply(
9242 &self,
9243 _: &crate::types::GASPInitialResponse,
9244 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9245 unreachable!()
9246 }
9247 async fn request_node(
9248 &self,
9249 _: &str,
9250 _: &str,
9251 _: u32,
9252 _: bool,
9253 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9254 unreachable!()
9255 }
9256 async fn submit_node(
9257 &self,
9258 _: &crate::types::GASPNode,
9259 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9260 unreachable!()
9261 }
9262 }
9263
9264 struct SelectiveHangFactory {
9268 created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
9269 }
9270
9271 impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
9272 fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
9273 self.created.borrow_mut().push(peer_url.to_string());
9274 if peer_url.contains("hang.example.com") {
9275 Box::new(HangingRemote)
9276 } else {
9277 Box::new(MockSyncRemote {
9278 utxos: vec![crate::types::GASPOutput {
9279 txid: "remote_tx1".to_string(),
9280 output_index: 0,
9281 score: 100.0,
9282 }],
9283 })
9284 }
9285 }
9286 }
9287
9288 fn instant_deadline() -> crate::engine::SleepFactory {
9293 std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
9294 }
9295
9296 fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
9297 let mut sync_config: SyncConfiguration = HashMap::new();
9298 sync_config.insert(
9299 "tm_test".to_string(),
9300 SyncTarget::Peers(vec![
9301 "https://hang.example.com".to_string(),
9302 "https://good.example.com".to_string(),
9303 ]),
9304 );
9305 Engine::new(
9306 HashMap::new(),
9307 HashMap::new(),
9308 Box::new(store),
9309 None,
9310 EngineConfig {
9311 sync_configuration: sync_config,
9312 ..Default::default()
9313 },
9314 )
9315 }
9316
9317 #[tokio::test]
9318 async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
9319 let store = std::rc::Rc::new(MemoryStorage::new());
9320 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
9321 let mut engine = two_peer_engine(store.clone());
9322 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
9323 created: created.clone(),
9324 }));
9325 engine.set_peer_sync_budget(instant_deadline(), 1);
9326
9327 let result = engine.start_gasp_sync().await.unwrap();
9328 let topic_result = &result.topics_synced["tm_test"];
9329
9330 assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
9333 assert!(
9334 topic_result.errors[0].contains("budget"),
9335 "{:?}",
9336 topic_result.errors
9337 );
9338 assert_eq!(
9339 created.borrow().as_slice(),
9340 ["https://hang.example.com", "https://good.example.com"],
9341 "both peers attempted this tick"
9342 );
9343
9344 let hang_cursor = store
9347 .get_last_interaction("https://hang.example.com", "tm_test")
9348 .await
9349 .unwrap();
9350 assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
9351 let good_cursor = store
9352 .get_last_interaction("https://good.example.com", "tm_test")
9353 .await
9354 .unwrap();
9355 assert_eq!(good_cursor, 0, "no synthetic graph completed");
9356
9357 let hang_health = store
9359 .get_peer_sync_health("hang.example.com", "tm_test")
9360 .await
9361 .unwrap();
9362 assert_eq!(hang_health.consecutive_failures, 1);
9363 let good_health = store
9364 .get_peer_sync_health("good.example.com", "tm_test")
9365 .await
9366 .unwrap();
9367 assert_eq!(good_health.consecutive_failures, 0);
9368 }
9369
9370 #[tokio::test]
9371 async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
9372 let store = std::rc::Rc::new(MemoryStorage::new());
9373 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
9374
9375 let mut sync_config: SyncConfiguration = HashMap::new();
9376 sync_config.insert(
9377 "tm_test".to_string(),
9378 SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
9379 );
9380 let mut engine = Engine::new(
9381 HashMap::new(),
9382 HashMap::new(),
9383 Box::new(store.clone()),
9384 None,
9385 EngineConfig {
9386 sync_configuration: sync_config,
9387 ..Default::default()
9388 },
9389 );
9390 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
9391 created: created.clone(),
9392 }));
9393 engine.set_peer_sync_budget(instant_deadline(), 1);
9394
9395 for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
9399 engine.start_gasp_sync().await.unwrap();
9400 store.advance_clock(900);
9401 assert_eq!(
9402 created.borrow().len() as u64,
9403 i + 1,
9404 "peer attempted while below the threshold"
9405 );
9406 }
9407 let health = store
9408 .get_peer_sync_health("hang.example.com", "tm_test")
9409 .await
9410 .unwrap();
9411 assert_eq!(
9412 health.consecutive_failures,
9413 crate::gasp::PEER_QUARANTINE_THRESHOLD
9414 );
9415
9416 let result = engine.start_gasp_sync().await.unwrap();
9420 assert!(result.topics_synced["tm_test"].errors.is_empty());
9421 assert_eq!(
9422 created.borrow().len() as u64,
9423 crate::gasp::PEER_QUARANTINE_THRESHOLD,
9424 "quarantined peer must be skipped"
9425 );
9426
9427 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
9429 engine.start_gasp_sync().await.unwrap();
9430 assert_eq!(
9431 created.borrow().len() as u64,
9432 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
9433 "re-probe attempted after the window"
9434 );
9435 let health = store
9437 .get_peer_sync_health("hang.example.com", "tm_test")
9438 .await
9439 .unwrap();
9440 assert_eq!(
9441 health.consecutive_failures,
9442 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
9443 );
9444 let result = engine.start_gasp_sync().await.unwrap();
9445 assert!(
9446 result.topics_synced["tm_test"].errors.is_empty(),
9447 "re-quarantined"
9448 );
9449
9450 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
9454 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
9456 let result = engine.start_gasp_sync().await.unwrap();
9457 assert!(result.topics_synced["tm_test"].errors.is_empty());
9458 let health = store
9459 .get_peer_sync_health("hang.example.com", "tm_test")
9460 .await
9461 .unwrap();
9462 assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
9463 }
9464
9465 use crate::broadcaster::ArcBroadcaster;
9468
9469 struct MockArcBroadcaster {
9470 calls: Arc<Mutex<Vec<String>>>,
9471 should_fail: bool,
9472 }
9473
9474 impl MockArcBroadcaster {
9475 fn new() -> Self {
9476 Self {
9477 calls: Arc::new(Mutex::new(Vec::new())),
9478 should_fail: false,
9479 }
9480 }
9481
9482 fn failing() -> Self {
9483 Self {
9484 calls: Arc::new(Mutex::new(Vec::new())),
9485 should_fail: true,
9486 }
9487 }
9488 }
9489
9490 #[async_trait(?Send)]
9491 impl ArcBroadcaster for MockArcBroadcaster {
9492 async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
9493 self.calls.lock().unwrap().push(raw_tx_hex.to_string());
9494 if self.should_fail {
9495 Err("mock ARC failure".to_string())
9496 } else {
9497 Ok("mock_txid_from_arc".to_string())
9498 }
9499 }
9500 }
9501
9502 fn make_engine_with_arc(
9504 admit_indices: Vec<u32>,
9505 arc: MockArcBroadcaster,
9506 ) -> (Engine, Arc<Mutex<Vec<String>>>) {
9507 let arc_calls = arc.calls.clone();
9508
9509 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9510 managers.insert(
9511 "tm_test".to_string(),
9512 Box::new(MockTopicManager::admitting(admit_indices)),
9513 );
9514
9515 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
9516 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
9517
9518 let storage = Box::new(MemoryStorage::new());
9519
9520 let engine = Engine::with_all(
9521 managers,
9522 lookup_services,
9523 storage,
9524 None,
9525 None,
9526 Some(Box::new(arc)),
9527 None,
9528 EngineConfig::default(),
9529 );
9530
9531 (engine, arc_calls)
9532 }
9533
9534 #[tokio::test]
9537 async fn test_arc_broadcaster_called_on_current_tx() {
9538 let arc = MockArcBroadcaster::new();
9539 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9540
9541 let beef = test_tagged_beef(vec!["tm_test"]);
9542 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
9543
9544 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
9545
9546 let calls = arc_calls.lock().unwrap();
9547 assert_eq!(
9548 calls.len(),
9549 1,
9550 "ARC broadcaster should be called once for CurrentTx"
9551 );
9552 assert!(
9553 !calls[0].is_empty(),
9554 "ARC should receive non-empty raw tx hex"
9555 );
9556 }
9557
9558 #[tokio::test]
9559 async fn test_arc_broadcaster_not_called_on_historical_tx() {
9560 let arc = MockArcBroadcaster::new();
9561 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9562
9563 let beef = test_tagged_beef(vec!["tm_test"]);
9564 engine
9565 .submit(&beef, SubmitMode::HistoricalTx)
9566 .await
9567 .unwrap();
9568
9569 assert_eq!(
9570 arc_calls.lock().unwrap().len(),
9571 0,
9572 "ARC broadcaster should NOT be called for HistoricalTx"
9573 );
9574 }
9575
9576 #[tokio::test]
9577 async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
9578 let arc = MockArcBroadcaster::new();
9579 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9580
9581 let beef = test_tagged_beef(vec!["tm_test"]);
9582 engine
9583 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
9584 .await
9585 .unwrap();
9586
9587 assert_eq!(
9588 arc_calls.lock().unwrap().len(),
9589 0,
9590 "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
9591 );
9592 }
9593
9594 #[tokio::test]
9595 async fn test_arc_broadcast_failure_does_not_fail_submit() {
9596 let arc = MockArcBroadcaster::failing();
9597 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9598
9599 let beef = test_tagged_beef(vec!["tm_test"]);
9600 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
9602
9603 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
9604
9605 assert_eq!(
9607 arc_calls.lock().unwrap().len(),
9608 1,
9609 "ARC broadcaster should be attempted even if it will fail"
9610 );
9611 }
9612
9613 #[tokio::test]
9614 async fn test_arc_broadcaster_not_present_still_works() {
9615 let engine = make_engine(vec![0]);
9617
9618 let beef = test_tagged_beef(vec!["tm_test"]);
9619 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
9620
9621 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
9622 }
9623
9624 struct TrackingAdvertiser {
9628 created: Arc<Mutex<Vec<AdvertisementData>>>,
9629 existing_ship: Vec<Advertisement>,
9630 existing_slap: Vec<Advertisement>,
9631 tag_topics: Vec<String>,
9635 create_fails: bool,
9637 find_fails: bool,
9639 revoke_empty: bool,
9641 }
9642
9643 impl TrackingAdvertiser {
9644 fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
9645 let created = Arc::new(Mutex::new(Vec::new()));
9646 (
9647 Self {
9648 created: created.clone(),
9649 existing_ship: vec![],
9650 existing_slap: vec![],
9651 tag_topics: vec!["tm_test".to_string()],
9652 create_fails: false,
9653 find_fails: false,
9654 revoke_empty: false,
9655 },
9656 created,
9657 )
9658 }
9659 }
9660
9661 use crate::advertiser::{Advertiser, AdvertiserError};
9662
9663 #[async_trait(?Send)]
9664 impl Advertiser for TrackingAdvertiser {
9665 async fn create_advertisements(
9666 &self,
9667 ads: &[AdvertisementData],
9668 ) -> Result<TaggedBEEF, AdvertiserError> {
9669 if self.create_fails {
9670 return Err(AdvertiserError::CreationFailed(
9671 "mock create refused".into(),
9672 ));
9673 }
9674 self.created.lock().unwrap().extend(ads.iter().cloned());
9675 Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
9676 }
9677
9678 async fn find_all_advertisements(
9679 &self,
9680 protocol: Protocol,
9681 ) -> Result<Vec<Advertisement>, AdvertiserError> {
9682 if self.find_fails {
9683 return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
9684 }
9685 match protocol {
9686 Protocol::Ship => Ok(self.existing_ship.clone()),
9687 Protocol::Slap => Ok(self.existing_slap.clone()),
9688 }
9689 }
9690
9691 async fn revoke_advertisements(
9692 &self,
9693 _ads: &[Advertisement],
9694 ) -> Result<TaggedBEEF, AdvertiserError> {
9695 if self.revoke_empty {
9696 return Ok(TaggedBEEF::new(vec![], vec![]));
9697 }
9698 Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
9699 }
9700
9701 fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
9702 None
9703 }
9704 }
9705
9706 #[tokio::test]
9710 async fn test_sync_advertisements_url_validation() {
9711 let (adv, created) = TrackingAdvertiser::new();
9713
9714 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9715 managers.insert(
9716 "tm_test".to_string(),
9717 Box::new(MockTopicManager::admitting(vec![0])),
9718 );
9719
9720 let engine = Engine::new(
9721 managers,
9722 HashMap::new(),
9723 Box::new(MemoryStorage::new()),
9724 Some(Box::new(adv)),
9725 EngineConfig {
9726 hosting_url: Some(String::new()), ..Default::default()
9728 },
9729 );
9730
9731 engine.sync_advertisements().await.unwrap();
9732 assert!(
9733 created.lock().unwrap().is_empty(),
9734 "Empty hosting URL should skip advertisement creation"
9735 );
9736
9737 let (adv2, created2) = TrackingAdvertiser::new();
9739
9740 let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9741 managers2.insert(
9742 "tm_test".to_string(),
9743 Box::new(MockTopicManager::admitting(vec![0])),
9744 );
9745 managers2.insert(
9747 "tm_ship".to_string(),
9748 Box::new(MockTopicManager::admitting(vec![])),
9749 );
9750
9751 let engine2 = Engine::new(
9752 managers2,
9753 HashMap::new(),
9754 Box::new(MemoryStorage::new()),
9755 Some(Box::new(adv2)),
9756 EngineConfig {
9757 hosting_url: Some("https://valid.example.com".to_string()),
9758 suppress_default_sync_advertisements: true,
9759 ..Default::default()
9760 },
9761 );
9762
9763 engine2.sync_advertisements().await.unwrap();
9764 let ads = created2.lock().unwrap();
9765 assert!(
9767 ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
9768 "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
9769 );
9770 assert!(
9771 ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
9772 "tm_test should get a SHIP advertisement"
9773 );
9774 }
9775
9776 fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
9785 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9786 managers.insert(
9787 "tm_test".to_string(),
9788 Box::new(MockTopicManager::admitting(vec![0])),
9789 );
9790 Engine::new(
9791 managers,
9792 HashMap::new(),
9793 Box::new(MemoryStorage::new()),
9794 Some(Box::new(adv)),
9795 EngineConfig {
9796 hosting_url: Some("https://valid.example.com".to_string()),
9797 suppress_default_sync_advertisements: true,
9798 ..Default::default()
9799 },
9800 )
9801 }
9802
9803 #[tokio::test]
9804 async fn sync_report_counts_admitted_outputs_on_success() {
9805 let (adv, _) = TrackingAdvertiser::new();
9806 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9807 assert!(report.ok(), "clean run must report ok: {report:?}");
9808 assert_eq!(report.to_create, 1, "tm_test was missing");
9809 assert_eq!(
9810 report.admitted.get("tm_test"),
9811 Some(&1),
9812 "the STEAK's admitted count must be surfaced: {report:?}"
9813 );
9814 }
9815
9816 #[tokio::test]
9817 async fn sync_report_surfaces_local_submit_failure() {
9818 let (mut adv, _) = TrackingAdvertiser::new();
9822 adv.tag_topics = vec!["tm_unregistered".to_string()];
9823 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9824 assert!(!report.ok(), "a failed local submit must not report ok");
9825 assert_eq!(report.to_create, 1);
9826 let submit_error = report.submit_error.as_deref().unwrap_or_default();
9827 assert!(
9828 submit_error.contains("tm_unregistered"),
9829 "verbatim engine error expected, got: {submit_error}"
9830 );
9831 assert!(report.admitted.is_empty());
9832 }
9833
9834 #[tokio::test]
9835 async fn sync_report_surfaces_create_failure() {
9836 let (mut adv, _) = TrackingAdvertiser::new();
9837 adv.create_fails = true;
9838 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9839 assert!(!report.ok());
9840 assert!(
9841 report
9842 .create_error
9843 .as_deref()
9844 .unwrap_or_default()
9845 .contains("mock create refused"),
9846 "{report:?}"
9847 );
9848 assert!(report.submit_error.is_none(), "create failed before submit");
9849 }
9850
9851 #[tokio::test]
9852 async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
9853 let (mut adv, created) = TrackingAdvertiser::new();
9857 adv.existing_ship = vec![Advertisement {
9858 protocol: Protocol::Ship,
9859 identity_key: "02aa".to_string(),
9860 domain: "https://valid.example.com".to_string(),
9861 topic_or_service: "tm_test".to_string(),
9862 beef: None,
9863 output_index: None,
9864 }];
9865 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9866 assert!(report.ok(), "{report:?}");
9867 assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
9868 assert!(created.lock().unwrap().is_empty());
9869 assert!(
9870 report.effective(),
9871 "converged-noop is the one zero-admit shape that IS success"
9872 );
9873 }
9874
9875 #[tokio::test]
9876 async fn sync_report_zero_admit_is_ok_but_not_effective() {
9877 let (adv, _) = TrackingAdvertiser::new();
9883 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9884 managers.insert(
9885 "tm_test".to_string(),
9886 Box::new(MockTopicManager::admitting(vec![])), );
9888 let engine = Engine::new(
9889 managers,
9890 HashMap::new(),
9891 Box::new(MemoryStorage::new()),
9892 Some(Box::new(adv)),
9893 EngineConfig {
9894 hosting_url: Some("https://valid.example.com".to_string()),
9895 suppress_default_sync_advertisements: true,
9896 ..Default::default()
9897 },
9898 );
9899 let report = engine.sync_advertisements().await.unwrap();
9900 assert!(report.ok(), "no ERROR occurred: {report:?}");
9901 assert_eq!(report.to_create, 1);
9902 assert_eq!(report.admitted_total(), 0, "{report:?}");
9903 assert!(
9904 !report.effective(),
9905 "zero admitted on a non-empty create must not read as success: {report:?}"
9906 );
9907 }
9908
9909 #[tokio::test]
9910 async fn sync_report_partial_admit_is_not_effective() {
9911 let (mut adv, _) = TrackingAdvertiser::new();
9917 adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
9918 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9919 managers.insert(
9920 "tm_test".to_string(),
9921 Box::new(MockTopicManager::admitting(vec![0])),
9922 );
9923 managers.insert(
9924 "tm_refuse".to_string(),
9925 Box::new(MockTopicManager::admitting(vec![])), );
9927 let engine = Engine::new(
9928 managers,
9929 HashMap::new(),
9930 Box::new(MemoryStorage::new()),
9931 Some(Box::new(adv)),
9932 EngineConfig {
9933 hosting_url: Some("https://valid.example.com".to_string()),
9934 suppress_default_sync_advertisements: true,
9935 ..Default::default()
9936 },
9937 );
9938 let report = engine.sync_advertisements().await.unwrap();
9939 assert!(report.ok(), "no ERROR occurred: {report:?}");
9940 assert_eq!(report.to_create, 2, "{report:?}");
9941 assert_eq!(report.admitted_total(), 1, "{report:?}");
9942 assert!(
9943 !report.effective(),
9944 "a partial admit must not read as success: {report:?}"
9945 );
9946 }
9947
9948 #[tokio::test]
9949 async fn sync_report_lookup_failure_refuses_creation() {
9950 let (mut adv, created) = TrackingAdvertiser::new();
9954 adv.find_fails = true;
9955 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9956 assert!(!report.ok(), "{report:?}");
9957 assert!(!report.effective());
9958 assert!(
9959 report
9960 .lookup_error
9961 .as_deref()
9962 .unwrap_or_default()
9963 .contains("mock lookup refused"),
9964 "{report:?}"
9965 );
9966 assert_eq!(report.to_create, 0, "creation refused, not attempted");
9967 assert!(
9968 created.lock().unwrap().is_empty(),
9969 "no create call on a blind read"
9970 );
9971 assert!(report.submit_error.is_none());
9972 }
9973
9974 #[tokio::test]
9975 async fn sync_report_counts_skipped_revocations() {
9976 let (mut adv, _) = TrackingAdvertiser::new();
9979 adv.revoke_empty = true;
9980 adv.existing_ship = vec![
9981 Advertisement {
9982 protocol: Protocol::Ship,
9983 identity_key: "02aa".to_string(),
9984 domain: "https://valid.example.com".to_string(),
9985 topic_or_service: "tm_test".to_string(),
9986 beef: None,
9987 output_index: None,
9988 },
9989 Advertisement {
9990 protocol: Protocol::Ship,
9991 identity_key: "02aa".to_string(),
9992 domain: "https://valid.example.com".to_string(),
9993 topic_or_service: "tm_stale".to_string(), beef: None,
9995 output_index: None,
9996 },
9997 ];
9998 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9999 assert!(report.ok(), "{report:?}");
10000 assert_eq!(report.to_revoke, 1, "tm_stale is stale");
10001 assert_eq!(
10002 report.revoke_skipped, 1,
10003 "the v1 no-op must be visible, not silent: {report:?}"
10004 );
10005 assert!(report.revoke_error.is_none());
10006 assert!(report.revoke_submit_error.is_none());
10007 }
10008
10009 use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
10012 use std::cell::Cell;
10013
10014 const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
10016
10017 fn proofless_beef() -> (Vec<u8>, String) {
10019 use bsv_rs::transaction::{Beef, Transaction};
10020 let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
10021 let txid = tx.id();
10022 let mut beef = Beef::new();
10023 beef.merge_transaction(tx);
10024 (beef.to_binary(), txid)
10025 }
10026
10027 fn subject_not_last_beef() -> (Vec<u8>, String, String) {
10037 use bsv_rs::transaction::Beef;
10038 let parsed = Beef::from_binary(&test_beef()).unwrap();
10039 let parent = parsed.txs[0].tx().unwrap().clone();
10040 let child = parsed.txs[1].tx().unwrap().clone();
10041 let (parent_id, child_id) = (parent.id(), child.id());
10042 assert_ne!(parent_id, child_id);
10043
10044 let mut beef = Beef::new();
10050 beef.merge_transaction(child); beef.merge_transaction(parent);
10052 let mut w = bsv_rs::primitives::encoding::Writer::new();
10053 beef.to_writer(&mut w);
10054 let bytes = w.into_bytes();
10055
10056 let reread = Beef::from_binary(&bytes).unwrap();
10059 assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
10060 (bytes, child_id, parent_id)
10061 }
10062
10063 #[test]
10064 fn stitch_preserves_the_subject_when_it_is_not_last() {
10065 use bsv_rs::transaction::{Beef, MerklePath};
10066 let (stored, child_id, parent_id) = subject_not_last_beef();
10067 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
10068
10069 let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
10070 .expect("stitch must succeed on a beef that contains the txid");
10071
10072 let after = Beef::from_binary(&out).unwrap();
10073 assert!(
10074 after.find_txid(&child_id).is_some(),
10075 "the SUBJECT must survive its own proof-completion"
10076 );
10077 assert!(
10078 after.find_txid(&parent_id).is_some(),
10079 "the ancestry must survive too"
10080 );
10081 assert!(
10082 after.find_bump(&child_id).is_some(),
10083 "the stitched bump must prove the subject"
10084 );
10085 assert!(
10086 after.find_bump(&parent_id).is_none(),
10087 "the parent must NOT be claimed by the child's bump"
10088 );
10089 }
10090
10091 #[test]
10096 fn the_old_round_trip_demonstrably_dropped_the_subject() {
10097 use bsv_rs::transaction::{Beef, Transaction};
10098 let (stored, child_id, _parent_id) = subject_not_last_beef();
10099 let tx = Transaction::from_beef(&stored, None).unwrap();
10100 assert_ne!(
10101 tx.id(),
10102 child_id,
10103 "from_beef(None) picks txs.last(), not the subject — the trigger"
10104 );
10105 let rebuilt = tx.to_beef(true).unwrap();
10106 let reparsed = Beef::from_binary(&rebuilt).unwrap();
10107 assert!(
10108 reparsed.find_txid(&child_id).is_none(),
10109 "the rebuild loses the subject — exactly the #284 artifact"
10110 );
10111 }
10112
10113 #[test]
10119 fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
10120 use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
10121 let parent = Transaction::from_hex(RAW_PARENT).unwrap();
10123 let parent_id = parent.id();
10124 let subject = child_of(&parent_id, 0);
10125 let subject_id = subject.id();
10126 let orphan = MerklePath::new_unchecked(
10129 965_771,
10130 vec![vec![
10131 MerklePathLeaf::new_txid(0, subject_id.clone()),
10132 MerklePathLeaf::new(1, "aa".repeat(32)),
10133 ]],
10134 )
10135 .unwrap();
10136 let mut beef = Beef::new();
10137 let bi = beef.merge_bump(orphan.clone());
10138 beef.merge_raw_tx(parent.to_binary(), None);
10139 beef.merge_raw_tx(subject.to_binary(), Some(bi));
10140 let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
10141 let stored = beef.to_binary();
10142 assert_eq!(
10143 Beef::from_binary(&stored)
10144 .unwrap()
10145 .find_bump(&subject_id)
10146 .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
10147 Some(orphan_root.clone())
10148 );
10149 let canonical = MerklePath::new_unchecked(
10151 965_771,
10152 vec![vec![
10153 MerklePathLeaf::new_txid(0, subject_id.clone()),
10154 MerklePathLeaf::new(1, "bb".repeat(32)),
10155 ]],
10156 )
10157 .unwrap();
10158 let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
10159 assert_ne!(
10160 canonical_root, orphan_root,
10161 "the reorg shape: same height, new root"
10162 );
10163 let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
10164 let after = Beef::from_binary(&out).unwrap();
10165 let own = after
10167 .find_txid(&subject_id)
10168 .and_then(BeefTx::bump_index)
10169 .and_then(|bi| after.bumps.get(bi))
10170 .unwrap();
10171 assert_eq!(
10172 own.compute_root(Some(&subject_id)).unwrap(),
10173 canonical_root,
10174 "the subject re-anchored to the new bump"
10175 );
10176 assert_eq!(
10178 after.bumps.len(),
10179 1,
10180 "the unreferenced orphan bump was dropped"
10181 );
10182 assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
10184 }
10185
10186 #[test]
10187 fn stitch_refuses_a_beef_that_lacks_the_txid() {
10188 use bsv_rs::transaction::MerklePath;
10189 let (stored, _c, _p) = subject_not_last_beef();
10190 let foreign = "aa".repeat(32);
10191 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
10192 assert!(
10193 Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
10194 "no txid in the beef -> no write, never garbage"
10195 );
10196 }
10197
10198 const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
10200
10201 fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
10203 let mut sx = String::from("0100000001");
10204 let mut prev = hex::decode(source_txid).unwrap();
10205 prev.reverse();
10206 sx.push_str(&hex::encode(prev));
10207 sx.push_str(&hex::encode(vout.to_le_bytes()));
10208 sx.push_str("00ffffffff01");
10209 sx.push_str(&hex::encode(1000u64.to_le_bytes()));
10210 sx.push_str("015100000000");
10211 bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
10212 }
10213
10214 fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
10216 use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
10217 let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
10218 MerklePath::new_unchecked(height, vec![vec![leaf]])
10219 .unwrap()
10220 .to_hex()
10221 }
10222
10223 struct StubFetcher {
10226 proof: Option<String>,
10227 calls: Cell<u32>,
10228 }
10229
10230 #[async_trait(?Send)]
10231 impl AncestorFetcher for StubFetcher {
10232 async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
10233 self.calls.set(self.calls.get() + 1);
10234 Ok(FetchedAncestor {
10235 raw_tx: PROOFLESS_RAW_TX.to_string(),
10236 proof: self.proof.clone(),
10237 })
10238 }
10239
10240 async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
10246 true
10247 }
10248 }
10249
10250 async fn engine_with_proofless_output(
10253 fetcher: Option<std::rc::Rc<StubFetcher>>,
10254 ) -> (Engine, String) {
10255 let (beef, txid) = proofless_beef();
10256 let storage = MemoryStorage::new();
10257 storage
10258 .insert_output(&Output {
10259 txid: txid.clone(),
10260 output_index: 0,
10261 output_script: vec![0x76],
10262 satoshis: 1_000_000_000,
10263 topic: "Hello".to_string(),
10264 spent: false,
10265 outputs_consumed: vec![],
10266 consumed_by: vec![],
10267 beef: Some(beef),
10268 block_height: None,
10269 score: Some(1.0),
10270 })
10271 .await
10272 .unwrap();
10273
10274 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10275 managers.insert(
10276 "Hello".into(),
10277 Box::new(MockTopicManager::admitting(vec![0])),
10278 );
10279 let mut engine = Engine::new(
10280 managers,
10281 HashMap::new(),
10282 Box::new(storage),
10283 None,
10284 EngineConfig::default(),
10285 );
10286 if let Some(f) = fetcher {
10287 engine.set_ancestor_fetcher(f);
10288 }
10289 (engine, txid)
10290 }
10291
10292 #[tokio::test]
10293 async fn proofs_completion_uses_dedicated_courier() {
10294 let (_, txid) = proofless_beef();
10295 let shared = std::rc::Rc::new(StubFetcher {
10296 proof: None,
10297 calls: Cell::new(0),
10298 });
10299 let dedicated = StubFetcher {
10300 proof: Some(single_leaf_bump_hex(&txid, 850_000)),
10301 calls: Cell::new(0),
10302 };
10303 let (engine, _) = engine_with_proofless_output(Some(shared.clone())).await;
10304 assert_eq!(
10305 engine
10306 .complete_missing_proofs(1, 0)
10307 .await
10308 .unwrap()
10309 .completed,
10310 0
10311 );
10312 assert_eq!(
10313 engine
10314 .complete_missing_proofs_with(&dedicated, 1, 0)
10315 .await
10316 .unwrap()
10317 .completed,
10318 1
10319 );
10320 assert_eq!(shared.calls.get(), 1);
10321 assert_eq!(dedicated.calls.get(), 1);
10322 let output = engine
10323 .storage()
10324 .find_output(&txid, 0, Some("Hello"), None, true)
10325 .await
10326 .unwrap()
10327 .unwrap();
10328 assert!(
10329 crate::beef_limits::own_proof(output.beef.as_ref().unwrap(), &txid)
10330 .unwrap()
10331 .is_some()
10332 );
10333 }
10334
10335 #[tokio::test]
10336 async fn complete_missing_proofs_noop_without_fetcher() {
10337 let (engine, _txid) = engine_with_proofless_output(None).await;
10339 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10340 assert_eq!(summary, ProofCompletionSummary::default());
10341 }
10342
10343 #[tokio::test]
10344 async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
10345 let (_beef, txid) = proofless_beef();
10346 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
10347 let fetcher = std::rc::Rc::new(StubFetcher {
10348 proof: Some(proof_hex),
10349 calls: Cell::new(0),
10350 });
10351 let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
10352
10353 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10354 assert_eq!(summary.scanned, 1);
10355 assert_eq!(summary.proofless, 1);
10356 assert_eq!(
10357 summary.completed, 1,
10358 "the proofless BEEF should be completed"
10359 );
10360 assert_eq!(fetcher.calls.get(), 1);
10361
10362 let out = engine
10365 .storage()
10366 .find_output(&txid, 0, Some("Hello"), None, true)
10367 .await
10368 .unwrap()
10369 .unwrap();
10370 assert_eq!(out.block_height, Some(850_000));
10371 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
10372 assert!(
10373 beef.find_txid(&txid)
10374 .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
10375 "completed BEEF must prove the target tx"
10376 );
10377
10378 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
10381 assert_eq!(summary2.proofless, 0);
10382 assert_eq!(summary2.completed, 0);
10383 assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
10384 }
10385
10386 #[tokio::test]
10387 async fn complete_missing_proofs_skips_unconfirmed() {
10388 let fetcher = std::rc::Rc::new(StubFetcher {
10391 proof: None,
10392 calls: Cell::new(0),
10393 });
10394 let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
10395 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10396 assert_eq!(summary.proofless, 1);
10397 assert_eq!(summary.completed, 0);
10398 assert_eq!(summary.still_unconfirmed, 1);
10399 assert_eq!(fetcher.calls.get(), 1);
10400 }
10401
10402 async fn engine_with_proofless_output_shared(
10407 fetcher: Option<std::rc::Rc<StubFetcher>>,
10408 ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
10409 let (beef, txid) = proofless_beef();
10410 let storage = std::rc::Rc::new(MemoryStorage::new());
10411 storage
10412 .insert_output(&Output {
10413 txid: txid.clone(),
10414 output_index: 0,
10415 output_script: vec![0x76],
10416 satoshis: 1_000_000_000,
10417 topic: "Hello".to_string(),
10418 spent: false,
10419 outputs_consumed: vec![],
10420 consumed_by: vec![],
10421 beef: Some(beef),
10422 block_height: None,
10423 score: Some(1.0),
10424 })
10425 .await
10426 .unwrap();
10427
10428 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10429 managers.insert(
10430 "Hello".into(),
10431 Box::new(MockTopicManager::admitting(vec![0])),
10432 );
10433 let mut engine = Engine::new(
10434 managers,
10435 HashMap::new(),
10436 Box::new(storage.clone()),
10437 None,
10438 EngineConfig::default(),
10439 );
10440 if let Some(f) = fetcher {
10441 engine.set_ancestor_fetcher(f);
10442 }
10443 (engine, txid, storage)
10444 }
10445
10446 #[tokio::test]
10447 async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
10448 let proof_hex = {
10452 let (_beef, txid) = proofless_beef();
10453 single_leaf_bump_hex(&txid, 850_000)
10454 };
10455 let fetcher = std::rc::Rc::new(StubFetcher {
10456 proof: Some(proof_hex),
10457 calls: Cell::new(0),
10458 });
10459 let (engine, _txid, storage) =
10460 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
10461
10462 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
10463 assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
10464 assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
10465
10466 storage.advance_clock(1800);
10471 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
10472 assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
10473 assert_eq!(summary.completed, 1, "the backstop completes the proof");
10474 assert_eq!(fetcher.calls.get(), 1);
10475 }
10476
10477 #[tokio::test]
10478 async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
10479 let fetcher = std::rc::Rc::new(StubFetcher {
10485 proof: None,
10486 calls: Cell::new(0),
10487 });
10488 let (engine, txid, storage) =
10489 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
10490
10491 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
10494 engine
10495 .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
10496 .await
10497 .unwrap();
10498
10499 storage.advance_clock(1_000_000);
10502 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
10503 assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
10504 assert_eq!(summary.completed, 0);
10505 assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
10506 }
10507
10508 #[tokio::test]
10509 async fn complete_missing_proofs_respects_limit() {
10510 let (beef1, txid1) = proofless_beef();
10512 let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
10514 let (beef2, txid2) = {
10515 use bsv_rs::transaction::{Beef, Transaction};
10516 let tx = Transaction::from_hex(raw2).unwrap();
10517 let id = tx.id();
10518 let mut b = Beef::new();
10519 b.merge_transaction(tx);
10520 (b.to_binary(), id)
10521 };
10522
10523 let storage = MemoryStorage::new();
10524 for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
10525 storage
10526 .insert_output(&Output {
10527 txid: txid.clone(),
10528 output_index: 0,
10529 output_script: vec![0x76],
10530 satoshis: 1000,
10531 topic: "Hello".to_string(),
10532 spent: false,
10533 outputs_consumed: vec![],
10534 consumed_by: vec![],
10535 beef: Some(beef),
10536 block_height: None,
10537 score: Some(1.0),
10538 })
10539 .await
10540 .unwrap();
10541 }
10542
10543 let fetcher = std::rc::Rc::new(StubFetcher {
10544 proof: None,
10545 calls: Cell::new(0),
10546 });
10547 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10548 managers.insert(
10549 "Hello".into(),
10550 Box::new(MockTopicManager::admitting(vec![0])),
10551 );
10552 let mut engine = Engine::new(
10553 managers,
10554 HashMap::new(),
10555 Box::new(storage),
10556 None,
10557 EngineConfig::default(),
10558 );
10559 engine.set_ancestor_fetcher(fetcher.clone());
10560
10561 let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
10562 assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
10563 }
10564
10565 #[tokio::test]
10566 async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
10567 use bsv_rs::transaction::{Beef, MerklePath, Transaction};
10576
10577 let storage = MemoryStorage::new();
10578
10579 let proven_raws = [
10584 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
10585 "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
10586 ];
10587 let mut proven_ids = Vec::new();
10588 for raw in proven_raws {
10589 let mut tx = Transaction::from_hex(raw).unwrap();
10590 let id = tx.id();
10591 tx.merkle_path =
10592 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
10593 let proven_beef = tx.to_beef(true).unwrap();
10596 assert_eq!(
10597 Beef::from_binary(&proven_beef)
10598 .unwrap()
10599 .find_txid(&id)
10600 .map(bsv_rs::transaction::BeefTx::has_proof),
10601 Some(true),
10602 "proven fixture row must carry its proof"
10603 );
10604 proven_ids.push(id.clone());
10605 storage
10606 .insert_output(&Output {
10607 txid: id,
10608 output_index: 0,
10609 output_script: vec![0x76],
10610 satoshis: 1000,
10611 topic: "Hello".to_string(),
10612 spent: false,
10613 outputs_consumed: vec![],
10614 consumed_by: vec![],
10615 beef: Some(proven_beef),
10616 block_height: Some(800_000),
10617 score: Some(1.0),
10618 })
10619 .await
10620 .unwrap();
10621 }
10622
10623 let fetcher = std::rc::Rc::new(StubFetcher {
10624 proof: None,
10625 calls: Cell::new(0),
10626 });
10627 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10628 managers.insert(
10629 "Hello".into(),
10630 Box::new(MockTopicManager::admitting(vec![0])),
10631 );
10632 let mut engine = Engine::new(
10633 managers,
10634 HashMap::new(),
10635 Box::new(storage),
10636 None,
10637 EngineConfig::default(),
10638 );
10639 engine.set_ancestor_fetcher(fetcher.clone());
10640
10641 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10644 assert_eq!(summary.scanned, proven_ids.len());
10645 assert_eq!(summary.proofless, 0);
10646 assert_eq!(summary.completed, 0);
10647 assert_eq!(
10648 summary.already_proven,
10649 proven_ids.len(),
10650 "every already-proven row is marked this pass"
10651 );
10652 assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
10653
10654 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
10657 assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
10658 assert_eq!(summary2.already_proven, 0);
10659 }
10660
10661 #[tokio::test]
10662 async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
10663 use bsv_rs::transaction::{MerklePath, Transaction};
10669
10670 let storage = MemoryStorage::new();
10671
10672 let proven_raws = [
10673 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
10674 ];
10675 for raw in proven_raws {
10676 let mut tx = Transaction::from_hex(raw).unwrap();
10677 let id = tx.id();
10678 tx.merkle_path =
10679 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
10680 let proven_beef = tx.to_beef(true).unwrap();
10681 storage
10682 .insert_output(&Output {
10683 txid: id,
10684 output_index: 0,
10685 output_script: vec![0x76],
10686 satoshis: 1000,
10687 topic: "Hello".to_string(),
10688 spent: false,
10689 outputs_consumed: vec![],
10690 consumed_by: vec![],
10691 beef: Some(proven_beef),
10692 block_height: Some(800_000),
10693 score: Some(1.0),
10694 })
10695 .await
10696 .unwrap();
10697 }
10698
10699 let (proofless, proofless_txid) = proofless_beef();
10701 storage
10702 .insert_output(&Output {
10703 txid: proofless_txid.clone(),
10704 output_index: 0,
10705 output_script: vec![0x76],
10706 satoshis: 1000,
10707 topic: "Hello".to_string(),
10708 spent: false,
10709 outputs_consumed: vec![],
10710 consumed_by: vec![],
10711 beef: Some(proofless),
10712 block_height: None,
10713 score: Some(1.0),
10714 })
10715 .await
10716 .unwrap();
10717
10718 let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
10719 let fetcher = std::rc::Rc::new(StubFetcher {
10720 proof: Some(proof_hex),
10721 calls: Cell::new(0),
10722 });
10723 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10724 managers.insert(
10725 "Hello".into(),
10726 Box::new(MockTopicManager::admitting(vec![0])),
10727 );
10728 let mut engine = Engine::new(
10729 managers,
10730 HashMap::new(),
10731 Box::new(storage),
10732 None,
10733 EngineConfig::default(),
10734 );
10735 engine.set_ancestor_fetcher(fetcher.clone());
10736
10737 let mut completed_total = 0;
10740 for _ in 0..5 {
10741 let s = engine.complete_missing_proofs(1, 0).await.unwrap();
10742 completed_total += s.completed;
10743 if completed_total > 0 {
10744 break;
10745 }
10746 }
10747 assert_eq!(
10748 completed_total, 1,
10749 "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
10750 );
10751
10752 let out = engine
10753 .storage()
10754 .find_output(&proofless_txid, 0, Some("Hello"), None, true)
10755 .await
10756 .unwrap()
10757 .unwrap();
10758 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
10759 assert_eq!(
10760 beef.find_txid(&proofless_txid)
10761 .map(bsv_rs::transaction::BeefTx::has_proof),
10762 Some(true),
10763 "the old proofless tx now carries its proof"
10764 );
10765 }
10766
10767 use crate::storage::StorageError;
10775 use std::rc::Rc;
10776
10777 struct FaultingStorage {
10782 inner: MemoryStorage,
10783 fail_insert_output: Cell<bool>,
10784 fail_find_output: Cell<bool>,
10785 }
10786
10787 impl FaultingStorage {
10788 fn new() -> Rc<Self> {
10789 Rc::new(Self {
10790 inner: MemoryStorage::new(),
10791 fail_insert_output: Cell::new(false),
10792 fail_find_output: Cell::new(false),
10793 })
10794 }
10795 }
10796
10797 #[async_trait(?Send)]
10798 impl Storage for FaultingStorage {
10799 async fn put_deferred_graph(
10800 &self,
10801 record: &crate::gasp::DeferredGraph,
10802 ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
10803 self.inner.put_deferred_graph(record).await
10804 }
10805 async fn find_deferred_graphs(
10806 &self,
10807 host: &str,
10808 topic: &str,
10809 ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
10810 self.inner.find_deferred_graphs(host, topic).await
10811 }
10812 async fn get_deferred_graph(
10813 &self,
10814 host: &str,
10815 topic: &str,
10816 outpoint: &str,
10817 ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
10818 self.inner.get_deferred_graph(host, topic, outpoint).await
10819 }
10820 async fn delete_deferred_graph(
10821 &self,
10822 host: &str,
10823 topic: &str,
10824 outpoint: &str,
10825 ) -> Result<(), StorageError> {
10826 self.inner
10827 .delete_deferred_graph(host, topic, outpoint)
10828 .await
10829 }
10830 async fn record_peer_sync_outcome(
10831 &self,
10832 host: &str,
10833 topic: &str,
10834 success: bool,
10835 ) -> Result<(), StorageError> {
10836 self.inner
10837 .record_peer_sync_outcome(host, topic, success)
10838 .await
10839 }
10840 async fn get_peer_sync_health(
10841 &self,
10842 host: &str,
10843 topic: &str,
10844 ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
10845 self.inner.get_peer_sync_health(host, topic).await
10846 }
10847 async fn record_peer_sync_yield(
10848 &self,
10849 host: &str,
10850 topic: &str,
10851 yielded: bool,
10852 ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
10853 self.inner
10854 .record_peer_sync_yield(host, topic, yielded)
10855 .await
10856 }
10857 async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
10858 if self.fail_insert_output.get() {
10859 return Err(StorageError::Database(
10860 "D1_ERROR: storage overloaded".into(),
10861 ));
10862 }
10863 self.inner.insert_output(output).await
10864 }
10865 async fn delete_output(
10866 &self,
10867 txid: &str,
10868 output_index: u32,
10869 topic: &str,
10870 ) -> Result<(), StorageError> {
10871 self.inner.delete_output(txid, output_index, topic).await
10872 }
10873 async fn mark_utxo_as_spent(
10874 &self,
10875 txid: &str,
10876 output_index: u32,
10877 topic: &str,
10878 ) -> Result<(), StorageError> {
10879 self.inner
10880 .mark_utxo_as_spent(txid, output_index, topic)
10881 .await
10882 }
10883 async fn update_consumed_by(
10884 &self,
10885 txid: &str,
10886 output_index: u32,
10887 topic: &str,
10888 consumed_by: &[Outpoint],
10889 ) -> Result<(), StorageError> {
10890 self.inner
10891 .update_consumed_by(txid, output_index, topic, consumed_by)
10892 .await
10893 }
10894 async fn update_transaction_beef(
10895 &self,
10896 txid: &str,
10897 beef: &[u8],
10898 ) -> Result<(), StorageError> {
10899 self.inner.update_transaction_beef(txid, beef).await
10900 }
10901 async fn insert_applied_transaction(
10902 &self,
10903 tx: &AppliedTransaction,
10904 ) -> Result<(), StorageError> {
10905 self.inner.insert_applied_transaction(tx).await
10906 }
10907 async fn does_applied_transaction_exist(
10908 &self,
10909 tx: &AppliedTransaction,
10910 ) -> Result<bool, StorageError> {
10911 self.inner.does_applied_transaction_exist(tx).await
10912 }
10913 async fn delete_applied_transaction(
10914 &self,
10915 tx: &AppliedTransaction,
10916 ) -> Result<(), StorageError> {
10917 self.inner.delete_applied_transaction(tx).await
10918 }
10919 async fn find_output(
10920 &self,
10921 txid: &str,
10922 output_index: u32,
10923 topic: Option<&str>,
10924 spent: Option<bool>,
10925 include_beef: bool,
10926 ) -> Result<Option<Output>, StorageError> {
10927 if self.fail_find_output.get() {
10928 return Err(StorageError::Database("D1_ERROR: read timed out".into()));
10929 }
10930 self.inner
10931 .find_output(txid, output_index, topic, spent, include_beef)
10932 .await
10933 }
10934 async fn find_outputs_for_transaction(
10935 &self,
10936 txid: &str,
10937 include_beef: bool,
10938 ) -> Result<Vec<Output>, StorageError> {
10939 self.inner
10940 .find_outputs_for_transaction(txid, include_beef)
10941 .await
10942 }
10943 async fn find_utxos_for_topic(
10944 &self,
10945 topic: &str,
10946 since: Option<f64>,
10947 limit: Option<u64>,
10948 include_beef: bool,
10949 ) -> Result<Vec<Output>, StorageError> {
10950 self.inner
10951 .find_utxos_for_topic(topic, since, limit, include_beef)
10952 .await
10953 }
10954 async fn update_last_interaction(
10955 &self,
10956 host: &str,
10957 topic: &str,
10958 since: u64,
10959 ) -> Result<(), StorageError> {
10960 self.inner.update_last_interaction(host, topic, since).await
10961 }
10962 async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
10963 self.inner.get_last_interaction(host, topic).await
10964 }
10965 }
10966
10967 fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
10968 let storage = FaultingStorage::new();
10969 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
10970 managers.insert(
10971 "tm_test".to_string(),
10972 Box::new(MockTopicManager::admitting(admit_indices)),
10973 );
10974 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
10975 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
10976 let engine = Engine::new(
10977 managers,
10978 lookup_services,
10979 Box::new(Rc::clone(&storage)),
10980 None,
10981 EngineConfig::default(),
10982 );
10983 (engine, storage)
10984 }
10985
10986 async fn applied(storage: &FaultingStorage) -> bool {
10987 storage
10988 .does_applied_transaction_exist(&AppliedTransaction {
10989 txid: TEST_TXID.to_string(),
10990 topic: "tm_test".to_string(),
10991 })
10992 .await
10993 .unwrap()
10994 }
10995
10996 #[tokio::test]
10999 async fn clean_submit_is_durable_and_records_applied() {
11000 let (engine, storage) = make_faulting_engine(vec![0]);
11001 let (steak, report) = engine
11002 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11003 .await
11004 .unwrap();
11005 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
11006 assert!(report.is_durable(), "{}", report.summary());
11007 assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
11008 assert!(applied(&storage).await);
11009 assert!(storage
11010 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
11011 .await
11012 .unwrap()
11013 .is_some());
11014 }
11015
11016 #[tokio::test]
11021 async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
11022 let (engine, storage) = make_faulting_engine(vec![0]);
11023 storage.fail_insert_output.set(true);
11024 let (steak, report) = engine
11025 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11026 .await
11027 .unwrap();
11028 assert_eq!(
11029 steak["tm_test"].outputs_to_admit,
11030 vec![0],
11031 "the admission DECISION is unchanged by a write fault"
11032 );
11033 assert!(!report.is_durable());
11034 assert_eq!(report.faults.len(), 1, "{}", report.summary());
11035 assert_eq!(report.faults[0].topic, "tm_test");
11036 assert_eq!(report.faults[0].site, "insert_output");
11037 assert!(report.faults[0].error.contains("D1_ERROR"));
11038 assert!(report.applied_topics.is_empty());
11039 assert!(
11040 !applied(&storage).await,
11041 "a faulted topic must not be recorded as applied — the replay would be a dupe"
11042 );
11043 assert!(storage
11044 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
11045 .await
11046 .unwrap()
11047 .is_none());
11048 }
11049
11050 #[tokio::test]
11054 async fn replay_after_a_fault_reapplies_and_records_applied() {
11055 let (engine, storage) = make_faulting_engine(vec![0]);
11056 storage.fail_insert_output.set(true);
11057 let (_, first) = engine
11058 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11059 .await
11060 .unwrap();
11061 assert!(!first.is_durable());
11062
11063 storage.fail_insert_output.set(false);
11064 let (steak, replay) = engine
11065 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11066 .await
11067 .unwrap();
11068 assert_eq!(
11069 steak["tm_test"].outputs_to_admit,
11070 vec![0],
11071 "re-validated, not deduped"
11072 );
11073 assert!(replay.is_durable(), "{}", replay.summary());
11074 assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
11075 assert!(applied(&storage).await);
11076 assert!(storage
11077 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
11078 .await
11079 .unwrap()
11080 .is_some());
11081
11082 let (steak, third) = engine
11083 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11084 .await
11085 .unwrap();
11086 assert!(
11087 steak["tm_test"].outputs_to_admit.is_empty(),
11088 "now a genuine dupe: nothing admitted"
11089 );
11090 assert!(third.is_durable() && third.applied_topics.is_empty());
11091 }
11092
11093 #[tokio::test]
11099 async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
11100 let (engine, storage) = make_faulting_engine(vec![0]);
11101 let tx = Transaction::from_beef(&test_beef(), None).unwrap();
11104 let source_txid = tx.inputs[0].get_source_txid().unwrap();
11105 let source_vout = tx.inputs[0].source_output_index;
11106 storage
11107 .insert_output(&Output {
11108 txid: source_txid.clone(),
11109 output_index: source_vout,
11110 output_script: vec![0x51],
11111 satoshis: 1,
11112 topic: "tm_test".to_string(),
11113 spent: false,
11114 outputs_consumed: vec![],
11115 consumed_by: vec![],
11116 beef: None,
11117 block_height: None,
11118 score: Some(1.0),
11119 })
11120 .await
11121 .unwrap();
11122
11123 storage.fail_find_output.set(true);
11124 let (_, faulted) = engine
11125 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11126 .await
11127 .unwrap();
11128 assert!(!faulted.is_durable());
11129 assert!(
11130 faulted.faults.iter().any(|f| f.site == "find_output"),
11131 "{}",
11132 faulted.summary()
11133 );
11134 assert!(
11135 !applied(&storage).await,
11136 "unrecorded: the replay must re-read"
11137 );
11138 storage.fail_find_output.set(false);
11139 assert!(
11140 storage
11141 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
11142 .await
11143 .unwrap()
11144 .is_some(),
11145 "the coin the faulted pass could not see is still there, untouched"
11146 );
11147
11148 let (_, replay) = engine
11149 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11150 .await
11151 .unwrap();
11152 assert!(replay.is_durable(), "{}", replay.summary());
11153 assert!(applied(&storage).await);
11154 assert!(
11157 storage
11158 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
11159 .await
11160 .unwrap()
11161 .is_none(),
11162 "the healthy replay consumed the coin the faulted pass missed"
11163 );
11164 }
11165
11166 #[tokio::test]
11173 async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
11174 use std::cell::RefCell;
11175 use std::rc::Rc;
11176
11177 struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
11178 #[async_trait(?Send)]
11179 impl LookupServiceTrait for CountingLs {
11180 fn admission_mode(&self) -> AdmissionMode {
11181 self.1
11182 }
11183 fn spend_notification_mode(&self) -> SpendNotificationMode {
11184 SpendNotificationMode::None
11185 }
11186 async fn output_admitted_by_topic(
11187 &self,
11188 payload: &OutputAdmittedByTopic,
11189 ) -> Result<(), LookupServiceError> {
11190 let rec = match payload {
11191 OutputAdmittedByTopic::LockingScript {
11192 txid,
11193 output_index,
11194 topic,
11195 ..
11196 } => (txid.clone(), *output_index, topic.clone()),
11197 OutputAdmittedByTopic::WholeTx {
11198 atomic_beef,
11199 output_index,
11200 topic,
11201 ..
11202 } => {
11203 let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
11204 (
11205 beef.atomic_txid
11206 .clone()
11207 .expect("the WholeTx body NAMES its subject"),
11208 *output_index,
11209 topic.clone(),
11210 )
11211 }
11212 };
11213 self.0.borrow_mut().push(rec);
11214 Ok(())
11215 }
11216 async fn output_evicted(
11217 &self,
11218 _txid: &str,
11219 _output_index: u32,
11220 ) -> Result<(), LookupServiceError> {
11221 Ok(())
11222 }
11223 async fn lookup(
11224 &self,
11225 _question: &LookupQuestion,
11226 ) -> Result<LookupResult, LookupServiceError> {
11227 Ok(LookupResult::OutputList(Vec::new()))
11228 }
11229 async fn get_documentation(&self) -> String {
11230 "counting".to_string()
11231 }
11232 async fn get_metadata(&self) -> ServiceMetadata {
11233 ServiceMetadata {
11234 name: "counting-ls".to_string(),
11235 ..Default::default()
11236 }
11237 }
11238 }
11239
11240 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11241 managers.insert(
11242 "tm_test".to_string(),
11243 Box::new(MockTopicManager::admitting(vec![0])),
11244 );
11245 let seen = Rc::new(RefCell::new(Vec::new()));
11246 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
11247 lookup_services.insert(
11248 "ls_whole".to_string(),
11249 Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
11250 );
11251 lookup_services.insert(
11252 "ls_script".to_string(),
11253 Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
11254 );
11255 let engine = Engine::new(
11256 managers,
11257 lookup_services,
11258 Box::new(MemoryStorage::new()),
11259 None,
11260 EngineConfig::default(),
11261 );
11262
11263 let tagged = test_tagged_beef(vec!["tm_test"]);
11264 engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
11265 assert_eq!(seen.borrow().len(), 2, "one admit per service");
11266 let (txid, vout, _) = seen.borrow()[0].clone();
11267 assert_eq!(txid, TEST_TXID);
11268
11269 let (_, report) = engine
11271 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11272 .await
11273 .unwrap();
11274 assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
11275 assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
11276
11277 let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
11279 assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
11280 assert_eq!(r.vouts, vec![vout]);
11281 assert_eq!(seen.borrow().len(), 4);
11282 assert!(seen.borrow()[2..]
11283 .iter()
11284 .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
11285
11286 let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
11288 assert_eq!((r0.outputs, r0.notified), (0, 0));
11289 assert_eq!(seen.borrow().len(), 4);
11290
11291 let r2 = engine
11293 .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
11294 .await
11295 .unwrap();
11296 assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
11297 assert_eq!(seen.borrow().len(), 6);
11298 }
11299
11300 #[tokio::test]
11305 async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
11306 let engine = make_engine(vec![]);
11308 let tagged = test_tagged_beef(vec!["tm_test"]);
11309 let (steak, r1) = engine
11310 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11311 .await
11312 .unwrap();
11313 assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
11314 assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
11315 let (_, r2) = engine
11316 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11317 .await
11318 .unwrap();
11319 assert_eq!(
11320 r2.deduped_topics,
11321 vec!["tm_test".to_string()],
11322 "the phantom row dedups the re-submit"
11323 );
11324 assert!(engine
11325 .forget_phantom_applied(TEST_TXID, "tm_test")
11326 .await
11327 .unwrap());
11328 let (_, r3) = engine
11329 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11330 .await
11331 .unwrap();
11332 assert!(
11333 r3.deduped_topics.is_empty(),
11334 "judged again after the forget"
11335 );
11336 assert!(
11337 !engine
11338 .forget_phantom_applied(TEST_TXID, "tm_other")
11339 .await
11340 .unwrap(),
11341 "no row: nothing to forget"
11342 );
11343
11344 let real = make_engine(vec![0]);
11346 real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
11347 assert!(!real
11348 .forget_phantom_applied(TEST_TXID, "tm_test")
11349 .await
11350 .unwrap());
11351 let (_, r4) = real
11352 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11353 .await
11354 .unwrap();
11355 assert_eq!(
11356 r4.deduped_topics,
11357 vec!["tm_test".to_string()],
11358 "a real admission still dedups"
11359 );
11360 }
11361
11362 const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
11368
11369 const WIDE_HEIGHT: u32 = 800_001;
11371
11372 fn wide_leaves(k: u32) -> Vec<String> {
11374 (0..1u32 << k)
11375 .map(|i| {
11376 let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
11377 id.reverse();
11378 hex::encode(id)
11379 })
11380 .collect()
11381 }
11382
11383 fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
11387 let k = txids.len().trailing_zeros() as usize;
11388 let mut levels = vec![Vec::new(); k];
11389 levels[0] = txids
11390 .iter()
11391 .enumerate()
11392 .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
11393 .collect();
11394 let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
11395 let root = bump.compute_root(None).unwrap();
11396 (bump, root)
11397 }
11398
11399 fn w1_bytes(k: u32) -> (Vec<u8>, String) {
11402 let (bump, root) = wide_bump(&wide_leaves(k));
11403 let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
11404 beef.extend_from_slice(&bump.to_binary());
11405 beef.push(0x00);
11406 (beef, root)
11407 }
11408
11409 fn walk_within_bound(
11413 bytes: Vec<u8>,
11414 root: String,
11415 verify_scripts: bool,
11416 subject: String,
11417 what: &str,
11418 ) -> (Result<(), String>, std::time::Duration) {
11419 let (tx, rx) = std::sync::mpsc::channel();
11420 std::thread::spawn(move || {
11421 let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
11422 tracker.add_root(WIDE_HEIGHT, root);
11423 let runtime = tokio::runtime::Builder::new_current_thread()
11424 .build()
11425 .unwrap();
11426 let started = std::time::Instant::now();
11427 let verdict = runtime.block_on(verify_spv_like_the_reference(
11428 Some(&tracker),
11429 verify_scripts,
11430 &bytes,
11431 &subject,
11432 DoorBudget::DEFAULT,
11433 ));
11434 let verdict = match verdict {
11435 Ok(None) => Ok(()),
11436 Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
11437 Err(e) => Err(e.to_string()),
11438 };
11439 let _ = tx.send((verdict, started.elapsed()));
11440 });
11441 rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
11442 panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
11443 })
11444 }
11445
11446 #[test]
11450 fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
11451 for k in [12u32, 13] {
11452 let (bytes, root) = w1_bytes(k);
11453 let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
11454 let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
11455 println!(
11456 "NL-6e W1 {what}: {verdict:?} in {:.3} s",
11457 took.as_secs_f64()
11458 );
11459 assert_eq!(verdict, Ok(()), "{what}");
11460 }
11461 }
11462
11463 fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
11468 use bsv_rs::script::{LockingScript, UnlockingScript};
11469 use bsv_rs::transaction::{TransactionInput, TransactionOutput};
11470 let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
11471 let spending = |txid: &str| {
11472 let mut input = TransactionInput::new(txid.to_string(), 0);
11473 input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
11474 input
11475 };
11476 let parents: Vec<Transaction> = (0..1u32 << k)
11477 .map(|i| {
11478 let mut tx = Transaction::new();
11479 let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
11481 input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
11482 tx.inputs.push(input);
11483 tx.outputs.push(TransactionOutput::new(1, op_true()));
11484 tx
11485 })
11486 .collect();
11487 let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
11488 let (bump, root) = wide_bump(&ids);
11489 let mut beef = Beef::new();
11490 let at = beef.merge_bump(bump);
11491 for parent in &parents {
11492 beef.merge_raw_tx(parent.to_binary(), Some(at));
11493 }
11494 let mut layer = ids;
11495 while layer.len() > 1 {
11496 layer = layer
11497 .chunks(2)
11498 .map(|pair| {
11499 let mut tx = Transaction::new();
11500 tx.inputs.push(spending(&pair[0]));
11501 tx.inputs.push(spending(&pair[1]));
11502 tx.outputs.push(TransactionOutput::new(0, op_true()));
11503 beef.merge_raw_tx(tx.to_binary(), None);
11504 tx.id()
11505 })
11506 .collect();
11507 }
11508 (beef.to_binary(), root, layer.remove(0))
11509 }
11510
11511 #[test]
11512 fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
11513 for k in [12u32, 13] {
11514 let (bytes, root, subject) = proven_fan_in(k);
11515 let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
11516 let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
11517 println!(
11518 "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
11519 took.as_secs_f64()
11520 );
11521 assert_eq!(verdict, Ok(()), "{what}");
11522 }
11523 }
11524}