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