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 mut summary = ProofCompletionSummary::default();
4294
4295 let Some(fetcher) = self.ancestor_fetcher.clone() else {
4298 return Ok(summary);
4299 };
4300
4301 let candidates = self
4302 .storage
4303 .find_transactions_for_proof_check(limit, min_age_secs)
4304 .await
4305 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4306 summary.scanned = candidates.len();
4307
4308 for cand in candidates {
4309 let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4313 Ok(b) => b,
4314 Err(e) => {
4315 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4316 continue;
4317 }
4318 };
4319
4320 if let Some(bump_hex) = own_bump
4338 .as_ref()
4339 .map(bsv_rs::transaction::MerklePath::to_hex)
4340 {
4341 if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4342 let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4350 .ok()
4351 .map(|mp| u64::from(mp.block_height));
4352 if let Err(e) = self
4353 .storage
4354 .mark_transaction_proven_at(&cand.txid, height)
4355 .await
4356 {
4357 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4358 } else {
4359 summary.already_proven += 1;
4360 }
4361 continue;
4362 }
4363 warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4364 }
4365 summary.proofless += 1;
4367
4368 let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4373 summary.still_unconfirmed += 1;
4374 continue;
4375 };
4376
4377 let block_height =
4379 beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4380 .ok()
4381 .map(|mp| mp.block_height);
4382
4383 match self
4384 .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4385 .await
4386 {
4387 Ok(()) => summary.completed += 1,
4388 Err(e) => {
4389 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4390 summary.stitch_failed += 1;
4391 }
4392 }
4393 }
4394
4395 info!(
4396 scanned = summary.scanned,
4397 proofless = summary.proofless,
4398 completed = summary.completed,
4399 still_unconfirmed = summary.still_unconfirmed,
4400 fetch_failed = summary.fetch_failed,
4401 stitch_failed = summary.stitch_failed,
4402 already_proven = summary.already_proven,
4403 "[PROOF COMPLETION] pass complete"
4404 );
4405
4406 Ok(summary)
4407 }
4408
4409 pub fn stitch_proof_into_stored_beef(
4450 stored: &[u8],
4451 txid: &str,
4452 proof: &bsv_rs::transaction::MerklePath,
4453 ) -> Option<Vec<u8>> {
4454 let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4455 beef.find_txid(txid)?;
4456 let i = beef.merge_bump(proof.clone());
4457 let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4461 b.path.first().is_some_and(|leaves| {
4462 leaves
4463 .iter()
4464 .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4465 })
4466 });
4467 if proves_subject {
4468 if let Some(tx) = beef.find_txid_mut(txid) {
4469 tx.set_bump_index(Some(i));
4470 }
4471 }
4472 Self::gc_unreferenced_bumps(&mut beef);
4473 Some(beef.to_binary())
4474 }
4475
4476 fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4482 let referenced: std::collections::HashSet<usize> = beef
4483 .txs
4484 .iter()
4485 .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4486 .collect();
4487 if referenced.len() == beef.bumps.len() {
4488 return;
4489 }
4490 let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4491 let mut kept = Vec::with_capacity(referenced.len());
4492 for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4493 if referenced.contains(&old) {
4494 remap[old] = Some(kept.len());
4495 kept.push(bump);
4496 }
4497 }
4498 beef.bumps = kept;
4499 for tx in &mut beef.txs {
4500 if let Some(old) = tx.bump_index() {
4501 tx.set_bump_index(remap[old]);
4502 }
4503 }
4504 }
4505
4506 pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4520 let mut report = SyncAdvertisementsReport::default();
4521
4522 let Some(advertiser) = &self.advertiser else {
4523 return Ok(report); };
4525
4526 let hosting_url = match &self.config.hosting_url {
4527 Some(url) if !url.is_empty() => url.clone(),
4528 _ => return Ok(report), };
4530
4531 let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4533 let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4534
4535 if self.config.suppress_default_sync_advertisements {
4536 configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4537 configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4538 }
4539
4540 let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4545 Ok(ads) => ads,
4546 Err(e) => {
4547 error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4548 report.lookup_error = Some(format!("ship: {e}"));
4549 return Ok(report);
4550 }
4551 };
4552 let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4553 Ok(ads) => ads,
4554 Err(e) => {
4555 error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4556 report.lookup_error = Some(format!("slap: {e}"));
4557 return Ok(report);
4558 }
4559 };
4560
4561 let ships_to_create: Vec<AdvertisementData> = configured_topics
4563 .iter()
4564 .filter(|topic| {
4565 !current_ship
4566 .iter()
4567 .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4568 })
4569 .map(|topic| AdvertisementData {
4570 protocol: Protocol::Ship,
4571 topic_or_service_name: topic.clone(),
4572 })
4573 .collect();
4574
4575 let slaps_to_create: Vec<AdvertisementData> = configured_services
4576 .iter()
4577 .filter(|service| {
4578 !current_slap
4579 .iter()
4580 .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4581 })
4582 .map(|service| AdvertisementData {
4583 protocol: Protocol::Slap,
4584 topic_or_service_name: service.clone(),
4585 })
4586 .collect();
4587
4588 let ships_to_revoke: Vec<Advertisement> = current_ship
4590 .into_iter()
4591 .filter(|a| !configured_topics.contains(&a.topic_or_service))
4592 .collect();
4593
4594 let slaps_to_revoke: Vec<Advertisement> = current_slap
4595 .into_iter()
4596 .filter(|a| !configured_services.contains(&a.topic_or_service))
4597 .collect();
4598
4599 let mut all_to_create = ships_to_create;
4601 all_to_create.extend(slaps_to_create);
4602 report.to_create = all_to_create.len();
4603 if !all_to_create.is_empty() {
4604 match advertiser.create_advertisements(&all_to_create).await {
4605 Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4606 Ok(steak) => {
4607 for (topic, instructions) in &steak {
4608 report
4609 .admitted
4610 .insert(topic.clone(), instructions.outputs_to_admit.len());
4611 }
4612 }
4613 Err(e) => {
4614 error!("Failed to submit new advertisements: {e}");
4615 report.submit_error = Some(e.to_string());
4616 }
4617 },
4618 Err(e) => {
4619 error!("Failed to create advertisements: {e}");
4620 report.create_error = Some(e.to_string());
4621 }
4622 }
4623 }
4624
4625 let mut all_to_revoke = ships_to_revoke;
4627 all_to_revoke.extend(slaps_to_revoke);
4628 report.to_revoke = all_to_revoke.len();
4629 if !all_to_revoke.is_empty() {
4630 match advertiser.revoke_advertisements(&all_to_revoke).await {
4631 Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4637 report.revoke_skipped = all_to_revoke.len();
4638 }
4639 Ok(tagged_beef) => {
4640 if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4641 error!("Failed to submit revocation: {e}");
4642 report.revoke_submit_error = Some(e.to_string());
4643 }
4644 }
4645 Err(e) => {
4646 error!("Failed to revoke advertisements: {e}");
4647 report.revoke_error = Some(e.to_string());
4648 }
4649 }
4650 }
4651
4652 Ok(report)
4653 }
4654
4655 #[allow(dead_code)] fn delete_utxo_deep<'a>(
4669 &'a self,
4670 output: &'a Output,
4671 bound: Option<&'a CallBound>,
4672 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4673 Box::pin(async move {
4674 if output.consumed_by.is_empty() {
4676 stored(
4677 bound,
4678 self.storage
4679 .delete_output(&output.txid, output.output_index, &output.topic),
4680 )
4681 .await
4682 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4683
4684 for ls in self.lookup_services.values() {
4687 let _ = hooked(
4688 bound,
4689 ls.output_no_longer_retained_in_history(
4690 &output.txid,
4691 output.output_index,
4692 &output.topic,
4693 ),
4694 )
4695 .await;
4696 }
4697 }
4698
4699 for consumed in &output.outputs_consumed {
4701 if let Ok(Some(mut stale_output)) = stored(
4702 bound,
4703 self.storage.find_output(
4704 &consumed.txid,
4705 consumed.output_index,
4706 Some(&output.topic),
4707 None,
4708 false,
4709 ),
4710 )
4711 .await
4712 {
4713 stale_output.consumed_by.retain(|c| {
4715 !(c.txid == output.txid && c.output_index == output.output_index)
4716 });
4717
4718 stored(
4719 bound,
4720 self.storage.update_consumed_by(
4721 &consumed.txid,
4722 consumed.output_index,
4723 &output.topic,
4724 &stale_output.consumed_by,
4725 ),
4726 )
4727 .await
4728 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4729
4730 let _ = self.delete_utxo_deep(&stale_output, bound).await;
4732 }
4733 }
4734
4735 Ok(())
4736 }) }
4738
4739 pub async fn renotify_admitted(
4770 &self,
4771 txid: &str,
4772 topic: &str,
4773 ) -> Result<RenotifyReport, EngineError> {
4774 let txid = txid.to_ascii_lowercase();
4775 let outputs = self
4776 .storage
4777 .find_outputs_for_transaction(&txid, true)
4778 .await
4779 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4780 let mut report = RenotifyReport::default();
4781 for output in outputs.iter().filter(|o| o.topic == topic) {
4782 report.outputs += 1;
4783 report.vouts.push(output.output_index);
4784 let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4787 beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4788 .ok()
4789 .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4790 .unwrap_or_else(|| beef.clone())
4791 });
4792 for (name, ls) in &self.lookup_services {
4793 let payload = match ls.admission_mode() {
4794 AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4795 txid: txid.clone(),
4796 output_index: output.output_index,
4797 topic: topic.to_string(),
4798 satoshis: output.satoshis,
4799 locking_script: output.output_script.clone(),
4800 off_chain_values: None,
4801 },
4802 AdmissionMode::WholeTx => {
4803 let Some(atomic_beef) = subject_named.as_ref() else {
4804 report.faults.push(format!(
4805 "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4806 output.output_index
4807 ));
4808 continue;
4809 };
4810 OutputAdmittedByTopic::WholeTx {
4811 atomic_beef: atomic_beef.clone(),
4812 output_index: output.output_index,
4813 topic: topic.to_string(),
4814 off_chain_values: None,
4815 }
4816 }
4817 };
4818 match ls.output_admitted_by_topic(&payload).await {
4819 Ok(()) => report.notified += 1,
4820 Err(e) => report.faults.push(format!("{name}: {e}")),
4821 }
4822 }
4823 }
4824 Ok(report)
4825 }
4826
4827 pub async fn forget_phantom_applied(
4838 &self,
4839 txid: &str,
4840 topic: &str,
4841 ) -> Result<bool, EngineError> {
4842 let txid = txid.to_ascii_lowercase();
4843 let outputs = self
4844 .storage
4845 .find_outputs_for_transaction(&txid, false)
4846 .await
4847 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4848 if outputs.iter().any(|o| o.topic == topic) {
4849 return Ok(false);
4850 }
4851 let rec = AppliedTransaction {
4852 txid: txid.clone(),
4853 topic: topic.to_string(),
4854 };
4855 let exists = self
4856 .storage
4857 .does_applied_transaction_exist(&rec)
4858 .await
4859 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4860 if !exists {
4861 return Ok(false);
4862 }
4863 self.storage
4864 .delete_applied_transaction(&rec)
4865 .await
4866 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4867 Ok(true)
4868 }
4869
4870 pub async fn evict_output(
4871 &self,
4872 txid: &str,
4873 output_index: u32,
4874 topic: Option<&str>,
4875 ) -> Result<(), EngineError> {
4876 if let Some(topic) = topic {
4877 self.storage
4879 .delete_output(txid, output_index, topic)
4880 .await
4881 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4882
4883 for ls in self.lookup_services.values() {
4885 let _ = ls.output_evicted(txid, output_index).await;
4886 }
4887 } else {
4888 let outputs = self
4890 .storage
4891 .find_outputs_for_transaction(txid, false)
4892 .await
4893 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4894
4895 for output in &outputs {
4896 if output.output_index == output_index {
4897 self.storage
4898 .delete_output(txid, output_index, &output.topic)
4899 .await
4900 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4901 }
4902 }
4903
4904 for ls in self.lookup_services.values() {
4906 let _ = ls.output_evicted(txid, output_index).await;
4907 }
4908 }
4909
4910 Ok(())
4911 }
4912
4913 pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
4938 use crate::gasp::{GASPSync, ENGINE_GASP_SYNC_PAGE_LIMIT};
4939 use crate::gasp_overlay::OverlayGASPStorage;
4940
4941 if self.config.sync_configuration.is_empty() {
4942 info!("[GASP SYNC] No sync configuration — nothing to sync");
4943 return Ok(GASPSyncResult {
4944 topics_synced: HashMap::new(),
4945 });
4946 }
4947
4948 let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
4949 let mut ship_lookup_hosts = None;
4952 let mut topic_order = sync_order(&self.config.sync_configuration);
4953 topic_order.sort_by_key(|(topic, target)| {
4956 !(matches!(target, SyncTarget::Ship)
4957 && self
4958 .ship_bootstrap_peers
4959 .get(topic.as_str())
4960 .is_some_and(|peers| !peers.is_empty()))
4961 });
4962
4963 for (topic, target) in topic_order {
4964 let configured_peers = matches!(target, SyncTarget::Peers(_));
4967 let (peers, sync_type) = match target {
4968 SyncTarget::Disabled => {
4969 info!("[GASP SYNC] Topic {topic} is disabled — skipping");
4970 continue;
4971 }
4972 SyncTarget::Ship => {
4973 info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
4974 let peers = self
4975 .discover_ship_peers(topic, &mut ship_lookup_hosts)
4976 .await;
4977 info!(
4978 "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
4979 peers.len()
4980 );
4981 (peers, "ship".to_string())
4982 }
4983 SyncTarget::Peers(peer_urls) => {
4984 info!(
4985 "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
4986 peer_urls.len()
4987 );
4988 let peers: Vec<String> = peer_urls
4989 .iter()
4990 .filter(|url| {
4991 if let Some(ref our_url) = self.config.hosting_url {
4992 url.trim_end_matches('/') != our_url.trim_end_matches('/')
4993 } else {
4994 true
4995 }
4996 })
4997 .cloned()
4998 .collect();
4999
5000 info!(
5001 "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
5002 peers.len()
5003 );
5004 (peers, "peers".to_string())
5005 }
5006 };
5007
5008 let mut errors = Vec::new();
5009 let mut pruned_inputs: u64 = 0;
5010 let mut discarded_graphs: u64 = 0;
5011 let mut expired_refused: u64 = 0;
5012 let expired = std::rc::Rc::default();
5013 let mut anchor_walk_held_graphs: u64 = 0;
5014 let mut finalized_graphs: u64 = 0;
5015 let mut deadline_dropped_graphs: u64 = 0;
5016 let mut cursor_moves: Vec<CursorMove> = Vec::new();
5017 let mut deferred_graphs: u64 = 0;
5018 let mut resumed_graphs: u64 = 0;
5019 let mut converged_graphs: u64 = 0;
5020 let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
5021 let mut stalled_graphs: u64 = 0;
5022 let mut held_back_graphs: u64 = 0;
5023
5024 if let Some(ref factory) = self.gasp_remote_factory {
5026 for peer_url in &peers {
5027 let health_key = crate::gasp::peer_origin(peer_url);
5040 let health = self
5041 .storage
5042 .get_peer_sync_health(&health_key, topic)
5043 .await
5044 .unwrap_or_default();
5045 if crate::gasp::peer_sync_quarantined(&health) {
5046 warn!(
5047 "[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)",
5048 health.consecutive_failures,
5049 health.secs_since_last_attempt,
5050 crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5051 );
5052 continue;
5053 }
5054
5055 info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
5056
5057 let last_interaction = self
5059 .storage
5060 .get_last_interaction(peer_url, topic)
5061 .await
5062 .unwrap_or(0);
5063
5064 let sink = crate::gasp_overlay::new_finalized_graph_sink();
5066
5067 let sync_fetcher = self
5076 .gasp_ancestor_fetcher
5077 .as_ref()
5078 .or(self.ancestor_fetcher.as_ref());
5079 let hydration_on = sync_fetcher.is_some();
5080
5081 let mut gasp_storage =
5083 OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5084 .with_peer(peer_url.as_str())
5085 .with_configured_peer(configured_peers)
5086 .with_strict_beef(hydration_on)
5087 .with_script_verification(self.verify_scripts)
5088 .with_walk_budget(self.walk_budget);
5089 if let Some(manager) = self.managers.get(topic) {
5090 gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5091 }
5092 if let Some(tracker) = self.chain_tracker.as_deref() {
5097 gasp_storage = gasp_storage.with_chain_tracker(tracker);
5098 }
5099 let gasp_remote = factory.create_remote(peer_url, topic);
5100
5101 let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5102 let peer_finalized = std::cell::Cell::new(0u64);
5104 let mut sync = GASPSync::new(
5105 Box::new(gasp_storage),
5106 gasp_remote,
5107 last_interaction,
5108 &log_prefix,
5109 true, )
5111 .with_ancestor_fetcher(sync_fetcher.cloned())
5112 .with_expired_outpoints(std::rc::Rc::clone(&expired));
5113 if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5116 let (sleep, budget_ms) = (sleep.clone(), *budget_ms);
5117 let half_sleep = sleep.clone();
5118 sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5119 max_calls: *max_calls,
5120 max_bytes_fetched: self.graph_budget_limbs.0,
5121 max_nodes: self.graph_budget_limbs.1,
5122 deadline: Box::new(move || sleep(budget_ms)),
5123 half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5124 });
5125 }
5126 if self.peer_sync_budget.is_some() {
5131 sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5132 engine: self,
5133 sink: sink.clone(),
5134 peer_url,
5135 topic,
5136 submitted: &peer_finalized,
5137 }));
5138 }
5139
5140 let sync_outcome = match &self.peer_sync_budget {
5150 Some((sleep, budget_ms)) => {
5151 crate::gasp::race_or_deadline_guarded(
5152 sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5153 sleep(*budget_ms),
5154 &self.finalize_gate,
5155 )
5156 .await
5157 }
5158 None => Some(sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)).await),
5159 };
5160
5161 let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5162 pruned_inputs += sync.pruned_inputs();
5168 expired_refused += sync.expired_refused();
5169 discarded_graphs += sync.discarded_graphs();
5174 anchor_walk_held_graphs += sync.anchor_walk_held();
5175 let ran = !matches!(sync_outcome, Some(Err(_)));
5179 let mut advanced_cursor = false;
5180 match sync_outcome {
5181 None => {
5182 let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5183 let msg = format!(
5184 "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5185 );
5186 warn!("[GASP SYNC] {msg}");
5187 errors.push(msg);
5188
5189 let in_flight = u64::from(sync.graph_in_flight());
5200 deadline_dropped_graphs += in_flight;
5201 sync.defer_in_flight().await;
5204 let completed = sync.completed_cursor();
5205 if completed > last_interaction {
5206 match self
5207 .storage
5208 .update_last_interaction(peer_url, topic, completed)
5209 .await
5210 {
5211 Ok(()) => cursor_moves.push(CursorMove {
5212 peer: peer_url.clone(),
5213 from: last_interaction,
5214 to: completed,
5215 }),
5216 Err(e) => warn!(
5217 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5218 ),
5219 }
5220 }
5221 advanced_cursor = completed > last_interaction;
5231 outcome_success = peer_finalized.get() > 0
5232 || completed > last_interaction
5233 || sync.deferral_stats().progressed > 0;
5234 warn!(
5235 "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5236 peer_finalized.get(),
5237 completed.max(last_interaction)
5238 );
5239 }
5240 Some(Ok(())) => {
5241 let landed = self
5245 .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5246 .await;
5247 let stats = sync.deferral_stats();
5258 if stats.stalled > 0
5259 && stats.progressed == 0
5260 && peer_finalized.get() == 0
5261 && !(landed && sync.last_interaction > last_interaction)
5262 {
5263 outcome_success = false;
5264 let msg = format!(
5265 "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5266 stats.stalled
5267 );
5268 warn!("[GASP SYNC] {msg}");
5269 errors.push(msg);
5270 }
5271
5272 advanced_cursor = landed && sync.last_interaction > last_interaction;
5291 if !landed {
5292 warn!(
5293 "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5294 );
5295 } else if sync.last_interaction > last_interaction {
5296 match self
5297 .storage
5298 .update_last_interaction(peer_url, topic, sync.last_interaction)
5299 .await
5300 {
5301 Ok(()) => cursor_moves.push(CursorMove {
5302 peer: peer_url.clone(),
5303 from: last_interaction,
5304 to: sync.last_interaction,
5305 }),
5306 Err(e) => warn!(
5307 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5308 ),
5309 }
5310 }
5311 info!(
5312 "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5313 sync.last_interaction
5314 );
5315 }
5316 Some(Err(e)) => {
5317 let msg = format!("{peer_url}: {e}");
5318 warn!("[GASP SYNC] Sync failed: {msg}");
5319 errors.push(msg);
5320 }
5321 }
5322 if ran && self.graph_budget.is_some() {
5333 let yielded = peer_finalized.get() > 0 || advanced_cursor;
5334 if yielded || sync.graphs_attempted() > 0 {
5335 match self
5336 .storage
5337 .record_peer_sync_yield(&health_key, topic, yielded)
5338 .await
5339 {
5340 Ok(streak)
5341 if !yielded
5342 && crate::gasp::yieldless_sync_failed(&streak)
5343 && outcome_success =>
5344 {
5345 outcome_success = false;
5346 let msg = format!(
5347 "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5348 streak.yieldless_syncs,
5349 streak.secs_since_first.unwrap_or(0),
5350 crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5351 crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5352 );
5353 warn!("[GASP SYNC] {msg}");
5354 errors.push(msg);
5355 }
5356 Ok(_) => {}
5357 Err(e) => warn!(
5358 "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5359 ),
5360 }
5361 }
5362 }
5363 finalized_graphs += peer_finalized.get();
5364 let deferral = sync.deferral_stats();
5365 deferred_graphs += deferral.deferred;
5366 resumed_graphs += deferral.resumed;
5367 converged_graphs += deferral.converged;
5368 stalled_graphs += deferral.stalled;
5369 held_back_graphs += deferral.held_back;
5370 dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5371 peer: peer_url.clone(),
5372 outpoint: d.outpoint,
5373 reason: d.reason.as_str().to_string(),
5374 }));
5375
5376 if outcome_success
5396 && !(peer_finalized.get() > 0 || advanced_cursor)
5397 && self.graph_budget.is_some()
5398 && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5399 {
5400 info!(
5401 "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5402 health.consecutive_failures
5403 );
5404 } else if let Err(e) = self
5405 .storage
5406 .record_peer_sync_outcome(&health_key, topic, outcome_success)
5407 .await
5408 {
5409 warn!(
5410 "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5411 );
5412 }
5413 }
5414 }
5415
5416 topics_synced.insert(
5417 topic.clone(),
5418 TopicSyncResult {
5419 expired_refused,
5420 peers,
5421 sync_type,
5422 errors,
5423 pruned_inputs,
5424 discarded_graphs,
5425 anchor_walk_held_graphs,
5426 finalized_graphs,
5427 deadline_dropped_graphs,
5428 cursor_moves,
5429 deferred_graphs,
5430 resumed_graphs,
5431 converged_graphs,
5432 dropped_graphs,
5433 stalled_graphs,
5434 held_back_graphs,
5435 },
5436 );
5437 }
5438
5439 info!(
5440 "[GASP SYNC] Peer discovery complete for {} topic(s)",
5441 topics_synced.len()
5442 );
5443
5444 let expired: u64 = topics_synced.values().map(|r| r.expired_refused).sum();
5445 if expired > 0 {
5446 info!("[GASP SYNC] gaspExpiredRefused={expired}");
5447 }
5448 Ok(GASPSyncResult { topics_synced })
5449 }
5450
5451 async fn submit_finalized_graphs(
5475 &self,
5476 sink: &crate::gasp_overlay::FinalizedGraphSink,
5477 peer_url: &str,
5478 topic: &str,
5479 submitted: &std::cell::Cell<u64>,
5480 ) -> bool {
5481 let finalized: Vec<_> = sink
5482 .lock()
5483 .unwrap_or_else(std::sync::PoisonError::into_inner)
5484 .drain(..)
5485 .collect();
5486
5487 let mut all_landed = true;
5488 for graph in &finalized {
5489 for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5490 self.finalize_gate.stop_if_due().await;
5491 if position == 0 {
5492 submitted.set(submitted.get() + 1);
5493 }
5494 let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5495 let outcome = {
5505 let _writing = self.finalize_gate.write_section();
5506 let bound = self
5507 .finalize_submit_budget
5508 .as_ref()
5509 .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5510 self.submit_bounded(
5511 &tagged,
5512 SubmitMode::HistoricalTxNoSpv,
5513 bound.as_ref(),
5514 true,
5515 WalkBreachPolicy::NotNow,
5516 )
5517 .await
5518 };
5519 let not_landed = match outcome {
5520 Ok((_, report)) => {
5521 let landed = report.is_durable()
5522 && (report.applied_topics.contains(&graph.topic)
5523 || report.deduped_topics.contains(&graph.topic));
5524 (!landed).then(|| {
5525 if report.is_durable() {
5526 "the topic manager failed on it".to_string()
5527 } else {
5528 format!("not durable: {}", report.summary())
5529 }
5530 })
5531 }
5532 Err(e) => Some(e.to_string()),
5533 };
5534 if let Some(why) = not_landed {
5535 warn!(
5536 "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
5537 graph.topic
5538 );
5539 all_landed = false;
5540 break;
5541 }
5542 }
5543 }
5544
5545 if !finalized.is_empty() {
5546 info!(
5547 "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
5548 finalized.len()
5549 );
5550 }
5551 all_landed
5552 }
5553
5554 pub async fn rerequest_held_proofs(
5560 &self,
5561 candidates: &[crate::proof_window::HeldOutput],
5562 progress: &std::cell::Cell<crate::proof_window::PeerProofSummary>,
5563 ) {
5564 use crate::proof_window::{PEER_PROOF_REQUEST_MS, PEER_PROOF_ROW_LIMIT};
5565 let Some(factory) = &self.gasp_remote_factory else {
5566 return;
5567 };
5568 let Some(tracker) = self.chain_tracker.as_deref() else {
5569 return;
5570 };
5571 let mut lookup_hosts = None;
5572 let mut topic_peers = HashMap::new();
5573 let mut landed = HashSet::new();
5574 for candidate in candidates.iter().take(PEER_PROOF_ROW_LIMIT) {
5575 if !self.managers.contains_key(&candidate.topic) {
5576 continue;
5577 }
5578 if let std::collections::hash_map::Entry::Vacant(entry) =
5579 topic_peers.entry(candidate.topic.clone())
5580 {
5581 let peers = match self.config.sync_configuration.get(&candidate.topic) {
5582 Some(SyncTarget::Ship) => {
5583 self.discover_ship_peers(&candidate.topic, &mut lookup_hosts)
5584 .await
5585 }
5586 Some(SyncTarget::Peers(peers)) => self.ship_peers_excluding_self(peers.clone()),
5587 _ => Vec::new(),
5588 };
5589 entry.insert(peers);
5590 }
5591 for peer in &topic_peers[&candidate.topic] {
5592 let remote = factory.create_remote(peer, &candidate.topic);
5593 let graph_id = format!("{}.{}", candidate.txid, candidate.output_index);
5594 let mut summary = progress.get();
5595 summary.requests += 1;
5596 progress.set(summary);
5597 let ask =
5598 remote.request_node(&graph_id, &candidate.txid, candidate.output_index, false);
5599 let response = if let Some((sleep, _)) = &self.peer_sync_budget {
5600 crate::gasp::race_or_deadline(ask, sleep(PEER_PROOF_REQUEST_MS)).await
5601 } else {
5602 Some(ask.await)
5603 };
5604 let Some(Ok(node)) = response else {
5605 let mut summary = progress.get();
5606 summary.faults += 1;
5607 progress.set(summary);
5608 continue;
5609 };
5610 if landed.contains(&candidate.txid) {
5611 continue;
5612 }
5613 let Some(proof_hex) = node.proof else {
5614 continue;
5615 };
5616 let Ok(mut proof) = beef_limits::merkle_path_from_hex(
5617 &proof_hex,
5618 beef_limits::COURIER_PROOF_MAX_BYTES,
5619 ) else {
5620 let mut summary = progress.get();
5621 summary.refused += 1;
5622 progress.set(summary);
5623 continue;
5624 };
5625 let valid = match proof.compute_root(Some(&candidate.txid)) {
5626 Ok(root) => tracker
5627 .is_valid_root_for_height(&root, proof.block_height)
5628 .await
5629 .unwrap_or(false),
5630 Err(_) => false,
5631 };
5632 if !valid {
5633 let mut summary = progress.get();
5634 summary.refused += 1;
5635 progress.set(summary);
5636 continue;
5637 }
5638 for leaf in &mut proof.path[0] {
5641 if leaf.hash.as_deref() == Some(&candidate.txid) {
5642 leaf.txid = true;
5643 }
5644 }
5645 match self.land_peer_proof(candidate, &proof).await {
5646 Ok(()) => {
5647 landed.insert(candidate.txid.clone());
5648 let mut summary = progress.get();
5649 summary.landed += 1;
5650 progress.set(summary);
5651 }
5652 Err(error) => {
5653 warn!(%error, "[proof window] verified proof could not be stored");
5654 let mut summary = progress.get();
5655 summary.faults += 1;
5656 progress.set(summary);
5657 }
5658 }
5659 }
5660 }
5661 }
5662
5663 async fn land_peer_proof(
5664 &self,
5665 candidate: &crate::proof_window::HeldOutput,
5666 proof: &bsv_rs::transaction::MerklePath,
5667 ) -> Result<(), EngineError> {
5668 let output = self
5669 .storage
5670 .find_output(
5671 &candidate.txid,
5672 candidate.output_index,
5673 Some(&candidate.topic),
5674 None,
5675 true,
5676 )
5677 .await
5678 .map_err(|e| EngineError::StorageError(e.to_string()))?
5679 .ok_or(EngineError::NodeNotFound)?;
5680 let beef = output
5681 .beef
5682 .ok_or_else(|| EngineError::BeefParseError("missing stored BEEF".into()))?;
5683 let stitched = Self::stitch_proof_into_stored_beef(&beef, &candidate.txid, proof)
5684 .ok_or_else(|| {
5685 EngineError::BeefParseError("proof does not stitch into stored subject".into())
5686 })?;
5687 self.storage
5688 .update_transaction_beef(&candidate.txid, &stitched)
5689 .await
5690 .map_err(|e| EngineError::StorageError(e.to_string()))?;
5691 self.storage
5692 .mark_transaction_proven_at(&candidate.txid, Some(u64::from(proof.block_height)))
5693 .await
5694 .map_err(|e| EngineError::StorageError(e.to_string()))
5695 }
5696
5697 async fn discover_ship_peers(
5698 &self,
5699 topic: &str,
5700 lookup_hosts: &mut Option<Vec<String>>,
5701 ) -> Vec<String> {
5702 let mut domains = self.local_ship_peers(topic).await;
5703 if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
5704 domains.extend(bootstrap.iter().cloned());
5705 }
5706 if let Some(client) = &self.ship_lookup_client {
5707 if lookup_hosts.is_none() {
5708 *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
5709 }
5710 let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
5711 for host in lookup_hosts.as_ref().into_iter().flatten() {
5712 match client.lookup(host, &question).await {
5713 Ok(outputs) => {
5714 domains.extend(outputs.iter().filter_map(|output| {
5715 parse_lookup_advertisement(output, b"SHIP", topic)
5716 }));
5717 }
5718 Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
5719 }
5720 }
5721 }
5722 self.ship_peers_excluding_self(domains)
5723 }
5724
5725 async fn discover_ship_lookup_hosts(
5726 &self,
5727 client: &dyn crate::ship_lookup::ShipLookupClient,
5728 ) -> Vec<String> {
5729 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
5730 let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
5731 let tracker_origins: HashSet<_> = trackers
5732 .iter()
5733 .map(|host| crate::gasp::peer_origin(host))
5734 .collect();
5735 let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
5736 let mut advertised = Vec::new();
5737 for tracker in &trackers {
5738 match client.lookup(tracker, &question).await {
5739 Ok(outputs) => {
5740 advertised.extend(outputs.iter().filter_map(|output| {
5741 parse_lookup_advertisement(output, b"SLAP", "ls_ship")
5742 }));
5743 }
5744 Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
5745 }
5746 }
5747 let providers = self
5748 .ship_peers_excluding_self(advertised)
5749 .into_iter()
5750 .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
5751 .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
5752 trackers.into_iter().chain(providers).collect()
5753 }
5754
5755 fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
5756 crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
5757 self.config.hosting_url.as_ref().is_none_or(|our_url| {
5758 crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
5759 })
5760 }))
5761 }
5762
5763 async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
5765 let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
5766 warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
5767 return Vec::new();
5768 };
5769
5770 let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
5771
5772 let refs = match ship_ls.lookup(&question).await {
5773 Ok(LookupResult::OutputList(refs)) => refs,
5774 Ok(LookupResult::Answer(_)) => {
5775 error!(
5776 "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
5777 LookupAnswer; expected OutputList. Skipping."
5778 );
5779 return Vec::new();
5780 }
5781 Err(e) => {
5782 error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
5783 return Vec::new();
5784 }
5785 };
5786
5787 let mut domains = HashSet::new();
5788 for reference in &refs {
5789 if let Ok(Some(output)) = self
5790 .storage
5791 .find_output(&reference.txid, reference.output_index, None, None, false)
5792 .await
5793 {
5794 if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
5795 if let Some(ref our_url) = self.config.hosting_url {
5799 if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
5800 || crate::gasp::peer_origin(&domain)
5801 == crate::gasp::peer_origin(our_url)
5802 {
5803 continue;
5804 }
5805 }
5806 domains.insert(domain);
5807 }
5808 }
5809 }
5810
5811 crate::gasp::ship_peers_by_origin(domains)
5815 }
5816
5817 pub fn storage(&self) -> &dyn Storage {
5823 self.storage.as_ref()
5824 }
5825
5826 pub fn config(&self) -> &EngineConfig {
5828 &self.config
5829 }
5830}
5831
5832pub(crate) async fn verify_spv_like_the_reference(
5849 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5850 verify_scripts: bool,
5851 beef_bytes: &[u8],
5852 subject_txid: &str,
5853 budget: DoorBudget,
5854) -> Result<Option<WalkCouldNotRun>, EngineError> {
5855 verify_spv_trusting(
5856 chain_tracker,
5857 verify_scripts,
5858 beef_bytes,
5859 subject_txid,
5860 &HashSet::new(),
5861 budget,
5862 )
5863 .await
5864}
5865
5866async fn verify_spv_trusting(
5871 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5872 verify_scripts: bool,
5873 beef_bytes: &[u8],
5874 subject_txid: &str,
5875 trusted: &HashSet<String>,
5876 budget: DoorBudget,
5877) -> Result<Option<WalkCouldNotRun>, EngineError> {
5878 if verify_scripts {
5879 Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
5880 } else {
5881 Engine::verify_spv_structurally(chain_tracker, beef_bytes)
5882 .await
5883 .map(|()| None)
5884 }
5885}
5886
5887fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
5891 let mut proven: HashMap<&str, usize> = HashMap::new();
5892 for (at, bump) in beef.bumps.iter().enumerate() {
5893 for leaf in bump.path.first().into_iter().flatten() {
5894 if let Some(hash) = leaf.hash.as_deref() {
5895 proven.entry(hash).or_insert(at);
5896 }
5897 }
5898 }
5899 proven
5900}
5901
5902fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
5907 let bodies: HashMap<String, &Transaction> = beef
5908 .txs
5909 .iter()
5910 .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
5911 .collect();
5912 let proven = proven_index(beef);
5913 let mut cover = HashSet::new();
5914 let mut queue = vec![from.to_string()];
5915 while let Some(txid) = queue.pop() {
5916 if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
5917 continue;
5918 }
5919 if let Some(tx) = bodies.get(&txid) {
5920 queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
5921 }
5922 }
5923 cover
5924}
5925
5926struct SubmitAsFinalized<'e> {
5929 engine: &'e Engine,
5930 sink: crate::gasp_overlay::FinalizedGraphSink,
5931 peer_url: &'e str,
5932 topic: &'e str,
5933 submitted: &'e std::cell::Cell<u64>,
5934}
5935
5936#[async_trait::async_trait(?Send)]
5937impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
5938 async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
5939 let landed = self
5943 .engine
5944 .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
5945 .await;
5946 if landed {
5947 Ok(())
5948 } else {
5949 Err(crate::gasp::GASPError::StorageError(
5952 "a finalize submit did not land".to_string(),
5953 ))
5954 }
5955 }
5956}
5957
5958#[derive(Debug, Clone, Serialize, Deserialize)]
5962pub struct GASPSyncResult {
5963 pub topics_synced: HashMap<String, TopicSyncResult>,
5965}
5966
5967#[derive(Debug, Clone, Serialize, Deserialize)]
5969pub struct TopicSyncResult {
5970 #[serde(default)]
5972 pub expired_refused: u64,
5973 pub peers: Vec<String>,
5975 pub sync_type: String,
5977 pub errors: Vec<String>,
5983 #[serde(default)]
5989 pub pruned_inputs: u64,
5990 #[serde(default)]
5995 pub discarded_graphs: u64,
5996 #[serde(default)]
6002 pub anchor_walk_held_graphs: u64,
6003 #[serde(default)]
6013 pub finalized_graphs: u64,
6014 #[serde(default)]
6022 pub deadline_dropped_graphs: u64,
6023 #[serde(default)]
6028 pub cursor_moves: Vec<CursorMove>,
6029 #[serde(default)]
6033 pub deferred_graphs: u64,
6034 #[serde(default)]
6036 pub resumed_graphs: u64,
6037 #[serde(default)]
6040 pub converged_graphs: u64,
6041 #[serde(default)]
6043 pub dropped_graphs: Vec<DroppedDeferral>,
6044 #[serde(default)]
6049 pub stalled_graphs: u64,
6050 #[serde(default)]
6054 pub held_back_graphs: u64,
6055}
6056
6057#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6060pub struct DroppedDeferral {
6061 pub peer: String,
6063 pub outpoint: String,
6065 pub reason: String,
6067}
6068
6069#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6072pub struct CursorMove {
6073 pub peer: String,
6075 pub from: u64,
6077 pub to: u64,
6079}
6080
6081pub(crate) fn current_timestamp_ms() -> f64 {
6086 #[cfg(target_arch = "wasm32")]
6087 {
6088 js_sys::Date::now()
6089 }
6090 #[cfg(not(target_arch = "wasm32"))]
6091 {
6092 std::time::SystemTime::now()
6093 .duration_since(std::time::UNIX_EPOCH)
6094 .map_or(0.0, |d| d.as_millis() as f64)
6095 }
6096}
6097
6098fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
6104 parse_advertisement_domain_from_script(output_script, b"SHIP", None)
6105}
6106
6107fn parse_lookup_advertisement(
6108 output: &OutputListItem,
6109 protocol: &[u8],
6110 scope: &str,
6111) -> Option<String> {
6112 let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
6113 .ok()?;
6114 let script = tx
6115 .outputs
6116 .get(output.output_index as usize)?
6117 .locking_script
6118 .to_binary();
6119 parse_advertisement_domain_from_script(&script, protocol, Some(scope))
6120}
6121
6122fn parse_advertisement_domain_from_script(
6123 output_script: &[u8],
6124 protocol: &[u8],
6125 scope: Option<&str>,
6126) -> Option<String> {
6127 use bsv_rs::script::templates::PushDrop;
6128
6129 let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
6130 let pushdrop = PushDrop::decode(&script.into()).ok()?;
6131
6132 if pushdrop.fields.len() < 3 {
6133 return None;
6134 }
6135
6136 if pushdrop.fields[0] != protocol {
6137 return None;
6138 }
6139
6140 if scope.is_some_and(|expected| {
6141 pushdrop
6142 .fields
6143 .get(3)
6144 .is_none_or(|actual| actual != expected.as_bytes())
6145 }) {
6146 return None;
6147 }
6148
6149 let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
6150 if domain.is_empty() {
6151 return None;
6152 }
6153
6154 Some(domain)
6155}
6156
6157#[derive(Debug, thiserror::Error)]
6166pub enum EngineError {
6167 #[error("This server does not support this topic: {0}")]
6168 UnsupportedTopic(String),
6169
6170 #[error("Lookup service not found for provider: {0}")]
6171 LookupServiceNotFound(String),
6172
6173 #[error("No matching output found!")]
6176 NodeNotFound,
6177
6178 #[error("lookup failed: {0}")]
6179 LookupFailed(String),
6180
6181 #[error("invalid query: {0}")]
6188 InvalidQuery(String),
6189
6190 #[error("storage error: {0}")]
6191 StorageError(String),
6192
6193 #[error("broadcast error: {0}")]
6194 BroadcastError(String),
6195
6196 #[error("SPV verification failed: {0}")]
6197 SpvError(String),
6198
6199 #[error("BEEF parsing failed: {0}")]
6200 BeefParseError(String),
6201
6202 #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6212 ScriptVerificationFailed {
6213 subject_txid: String,
6214 input_index: u32,
6215 reason: String,
6216 },
6217
6218 #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6225 ScriptWalkInconclusive {
6226 at_txid: String,
6227 subject_judged: bool,
6228 reason: String,
6229 },
6230
6231 #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6236 ScriptWalkOverBudget {
6237 at_txid: String,
6238 subject_judged: bool,
6239 limb: DoorLimb,
6240 what: String,
6241 },
6242
6243 #[error(
6253 "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6254 .0.limb.as_str(),
6255 .0.what
6256 )]
6257 WalkCouldNotRun(WalkCouldNotRun),
6258
6259 #[error("{0}")]
6260 Other(String),
6261}
6262
6263impl From<StorageError> for EngineError {
6264 fn from(e: StorageError) -> Self {
6265 EngineError::StorageError(e.to_string())
6266 }
6267}
6268
6269impl From<TopicManagerError> for EngineError {
6270 fn from(e: TopicManagerError) -> Self {
6271 EngineError::Other(e.to_string())
6272 }
6273}
6274
6275impl From<LookupServiceError> for EngineError {
6276 fn from(e: LookupServiceError) -> Self {
6277 EngineError::LookupFailed(e.to_string())
6278 }
6279}
6280
6281impl From<AdvertiserError> for EngineError {
6282 fn from(e: AdvertiserError) -> Self {
6283 EngineError::Other(e.to_string())
6284 }
6285}
6286
6287pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6297 let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6298 order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6299 order
6300}
6301
6302#[cfg(test)]
6303mod tests {
6304 use super::*;
6305 use crate::lookup_service::LookupService as LookupServiceTrait;
6306 use crate::storage::memory::MemoryStorage;
6307 use crate::topic_manager::TopicManager as TopicManagerTrait;
6308 use async_trait::async_trait;
6309 use std::sync::Mutex;
6310
6311 #[test]
6315 fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6316 let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6317 let config: SyncConfiguration = HashMap::from([
6318 ("tm_a".to_string(), SyncTarget::Ship),
6319 ("tm_z".to_string(), peers("https://z")),
6320 ("tm_b".to_string(), SyncTarget::Ship),
6321 ("tm_m".to_string(), peers("https://m")),
6322 ("tm_off".to_string(), SyncTarget::Disabled),
6323 ]);
6324 let order: Vec<&str> = sync_order(&config)
6325 .iter()
6326 .map(|(t, _)| t.as_str())
6327 .collect();
6328 assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6329 }
6330
6331 struct MockTopicManager {
6334 admit_indices: Vec<u32>,
6335 }
6336
6337 impl MockTopicManager {
6338 fn admitting(indices: Vec<u32>) -> Self {
6339 Self {
6340 admit_indices: indices,
6341 }
6342 }
6343 }
6344
6345 #[async_trait(?Send)]
6346 impl TopicManagerTrait for MockTopicManager {
6347 async fn identify_admissible_outputs(
6348 &self,
6349 _tx: &Transaction,
6350 _previous_coins: &[u8],
6351 _off_chain_values: Option<&[u8]>,
6352 _mode: SubmitMode,
6353 _context: &TopicAdmittanceContext,
6354 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6355 Ok(AdmittanceInstructions {
6356 outputs_to_admit: self.admit_indices.clone(),
6357 coins_to_retain: vec![],
6358 coins_removed: None,
6359 })
6360 }
6361
6362 async fn get_documentation(&self) -> String {
6363 "Mock topic manager".to_string()
6364 }
6365
6366 async fn get_metadata(&self) -> ServiceMetadata {
6367 ServiceMetadata {
6368 name: "mock-tm".to_string(),
6369 description: Some("Mock for testing".to_string()),
6370 ..Default::default()
6371 }
6372 }
6373 }
6374
6375 struct MockLookupService {
6378 records: Mutex<Vec<UTXOReference>>,
6379 }
6380
6381 impl MockLookupService {
6382 fn new() -> Self {
6383 Self {
6384 records: Mutex::new(Vec::new()),
6385 }
6386 }
6387 }
6388
6389 #[async_trait(?Send)]
6390 impl LookupServiceTrait for MockLookupService {
6391 fn admission_mode(&self) -> AdmissionMode {
6392 AdmissionMode::LockingScript
6393 }
6394
6395 fn spend_notification_mode(&self) -> SpendNotificationMode {
6396 SpendNotificationMode::None
6397 }
6398
6399 async fn output_admitted_by_topic(
6400 &self,
6401 payload: &OutputAdmittedByTopic,
6402 ) -> Result<(), LookupServiceError> {
6403 let (txid, oi) = match payload {
6404 OutputAdmittedByTopic::LockingScript {
6405 txid, output_index, ..
6406 } => (txid.clone(), *output_index),
6407 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
6408 ("whole".into(), *output_index)
6409 }
6410 };
6411 self.records.lock().unwrap().push(UTXOReference {
6412 txid,
6413 output_index: oi,
6414 });
6415 Ok(())
6416 }
6417
6418 async fn output_evicted(
6419 &self,
6420 txid: &str,
6421 output_index: u32,
6422 ) -> Result<(), LookupServiceError> {
6423 self.records
6424 .lock()
6425 .unwrap()
6426 .retain(|r| !(r.txid == txid && r.output_index == output_index));
6427 Ok(())
6428 }
6429
6430 async fn lookup(
6431 &self,
6432 _question: &LookupQuestion,
6433 ) -> Result<LookupResult, LookupServiceError> {
6434 Ok(LookupResult::OutputList(
6435 self.records.lock().unwrap().clone(),
6436 ))
6437 }
6438
6439 async fn get_documentation(&self) -> String {
6440 "Mock lookup service".to_string()
6441 }
6442
6443 async fn get_metadata(&self) -> ServiceMetadata {
6444 ServiceMetadata {
6445 name: "mock-ls".to_string(),
6446 ..Default::default()
6447 }
6448 }
6449 }
6450
6451 const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
6455 const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
6456
6457 fn decode_hex(hex: &str) -> Vec<u8> {
6458 (0..hex.len())
6459 .step_by(2)
6460 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
6461 .collect()
6462 }
6463
6464 fn test_beef() -> Vec<u8> {
6465 decode_hex(TEST_BEEF_HEX)
6466 }
6467
6468 fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
6469 TaggedBEEF::new(
6470 test_beef(),
6471 topics.into_iter().map(str::to_string).collect(),
6472 )
6473 }
6474
6475 fn make_engine(admit_indices: Vec<u32>) -> Engine {
6478 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6479 managers.insert(
6480 "tm_test".to_string(),
6481 Box::new(MockTopicManager::admitting(admit_indices)),
6482 );
6483
6484 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
6485 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
6486
6487 let storage = Box::new(MemoryStorage::new());
6488
6489 Engine::new(
6490 managers,
6491 lookup_services,
6492 storage,
6493 None,
6494 EngineConfig::default(),
6495 )
6496 }
6497
6498 #[tokio::test]
6507 async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
6508 use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
6509 use std::cell::RefCell;
6510 use std::rc::Rc;
6511
6512 let parsed = Beef::from_binary(&test_beef()).unwrap();
6515 let parent = parsed.txs[0].tx().unwrap().clone();
6516 let child = parsed.txs[1].tx().unwrap().clone();
6517 let child_id = child.id();
6518 let parent_id = parent.id();
6519 let mut beef = Beef::new();
6520 for bump in &parsed.bumps {
6526 beef.merge_bump(bump.clone());
6527 }
6528 beef.merge_transaction(child);
6529 beef.merge_transaction(parent);
6530 let mut w = bsv_rs::primitives::encoding::Writer::new();
6531 w.write_u32_le(ATOMIC_BEEF);
6532 let mut le = hex::decode(&child_id).unwrap();
6533 le.reverse();
6534 w.write_bytes(&le);
6535 beef.to_writer(&mut w);
6536 let atomic_wire_order = w.into_bytes();
6537
6538 struct RecordingTm(Rc<RefCell<Option<String>>>);
6540 #[async_trait(?Send)]
6541 impl TopicManagerTrait for RecordingTm {
6542 async fn identify_admissible_outputs(
6543 &self,
6544 tx: &Transaction,
6545 _previous_coins: &[u8],
6546 _off_chain_values: Option<&[u8]>,
6547 _mode: SubmitMode,
6548 _context: &TopicAdmittanceContext,
6549 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6550 *self.0.borrow_mut() = Some(tx.id());
6551 Ok(AdmittanceInstructions {
6552 outputs_to_admit: vec![0],
6553 coins_to_retain: vec![],
6554 coins_removed: None,
6555 })
6556 }
6557 async fn get_documentation(&self) -> String {
6558 String::new()
6559 }
6560 async fn get_metadata(&self) -> ServiceMetadata {
6561 ServiceMetadata {
6562 name: "tm_test".to_string(),
6563 ..Default::default()
6564 }
6565 }
6566 }
6567
6568 let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
6569 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6570 managers.insert(
6571 "tm_test".to_string(),
6572 Box::new(RecordingTm(Rc::clone(&seen))),
6573 );
6574 let engine = Engine::new(
6575 managers,
6576 HashMap::new(),
6577 Box::new(MemoryStorage::new()),
6578 None,
6579 EngineConfig::default(),
6580 );
6581
6582 let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
6583 let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
6584
6585 let handed = seen.borrow().clone().expect("TM was not invoked");
6586 assert_eq!(
6587 handed, child_id,
6588 "TM must be handed the atomic subject, got the wire-order pick"
6589 );
6590 assert_ne!(handed, parent_id);
6591 }
6592
6593 #[tokio::test]
6606 async fn history_hydration_preserves_the_stored_proof() {
6607 use bsv_rs::transaction::Beef;
6608
6609 let engine = make_engine(vec![0]);
6610 let stored = test_beef();
6611 let bumps_before = Beef::from_binary(&stored)
6612 .expect("fixture BEEF parses")
6613 .bumps
6614 .len();
6615 assert!(
6616 bumps_before > 0,
6617 "fixture must actually carry a proof, or this cell proves nothing"
6618 );
6619
6620 let output = Output {
6621 txid: TEST_TXID.to_string(),
6622 output_index: 0,
6623 output_script: vec![0x76, 0xa9],
6624 satoshis: 1000,
6625 topic: "tm_test".to_string(),
6626 spent: false,
6627 outputs_consumed: vec![],
6630 consumed_by: vec![],
6631 beef: Some(stored.clone()),
6632 block_height: None,
6633 score: Some(1000.0),
6634 };
6635
6636 let hydrated = engine
6637 .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
6638 .await
6639 .expect("hydration must not error")
6640 .expect("hydration must return the output");
6641 let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
6642 .expect("hydrated BEEF parses");
6643
6644 assert_eq!(
6645 beef.bumps.len(),
6646 bumps_before,
6647 "asking for history must not strip the merkle proof (issue #4)"
6648 );
6649 }
6650
6651 #[tokio::test]
6652 async fn test_submit_unsupported_topic_errors() {
6653 let engine = make_engine(vec![0]);
6654 let beef = test_tagged_beef(vec!["tm_nonexistent"]);
6655
6656 let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
6657 assert!(result.is_err());
6658 assert!(matches!(
6659 result.unwrap_err(),
6660 EngineError::UnsupportedTopic(_)
6661 ));
6662 }
6663
6664 #[tokio::test]
6665 async fn test_submit_admits_outputs() {
6666 let engine = make_engine(vec![0, 1]);
6667 let beef = test_tagged_beef(vec!["tm_test"]);
6668
6669 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6670
6671 let instructions = steak.get("tm_test").unwrap();
6673 assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
6674
6675 let txid = TEST_TXID;
6677 let found = engine
6678 .storage()
6679 .find_output(txid, 0, Some("tm_test"), None, true)
6680 .await
6681 .unwrap()
6682 .expect("Output 0 should be in storage");
6683
6684 assert_eq!(found.txid, TEST_TXID);
6685 assert_eq!(found.satoshis, 26172); assert!(
6687 !found.output_script.is_empty(),
6688 "Script should be populated"
6689 );
6690 assert!(found.beef.is_some(), "BEEF should be stored");
6691 assert!(!found.spent);
6692 }
6693
6694 #[tokio::test]
6695 async fn test_submit_duplicate_is_skipped() {
6696 let engine = make_engine(vec![0]);
6697 let beef = test_tagged_beef(vec!["tm_test"]);
6698
6699 let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6701 assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
6702
6703 let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6705 assert!(steak2["tm_test"].outputs_to_admit.is_empty());
6706 }
6707
6708 #[tokio::test]
6709 async fn test_submit_notifies_lookup_service() {
6710 let engine = make_engine(vec![0]);
6711 let beef = test_tagged_beef(vec!["tm_test"]);
6712
6713 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6714
6715 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6717 let answer = engine.lookup(&question, None).await.unwrap();
6718
6719 match answer {
6720 LookupAnswer::OutputList { outputs } => {
6721 assert_eq!(outputs.len(), 1);
6722 }
6723 _ => panic!("Expected OutputList"),
6724 }
6725 }
6726
6727 #[tokio::test]
6731 async fn test_lookup_with_txids_aligns_txids_with_outputs() {
6732 let engine = make_engine(vec![0]);
6733 let beef = test_tagged_beef(vec!["tm_test"]);
6734 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6735
6736 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6737 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
6738 match answer {
6739 LookupAnswer::OutputList { outputs } => {
6740 assert_eq!(outputs.len(), 1);
6741 assert_eq!(txids.len(), outputs.len(), "one txid per output");
6742 assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
6743 }
6744 _ => panic!("Expected OutputList"),
6745 }
6746 }
6747
6748 #[tokio::test]
6749 async fn proof_window_holds_an_aged_child_with_a_proven_parent() {
6750 let mut engine = make_engine(vec![0]);
6751 let storage = std::rc::Rc::new(MemoryStorage::new());
6752 engine.storage = Box::new(storage.clone());
6753 engine
6754 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
6755 .await
6756 .unwrap();
6757 assert!(beef_limits::own_proof(&test_beef(), TEST_TXID)
6758 .unwrap()
6759 .is_none());
6760 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6761 storage.advance_clock((crate::proof_window::PROOFLESS_FLAG_MS / 1000) as u64);
6762 assert_eq!(
6763 engine
6764 .lookup_with_txids(&question, None)
6765 .await
6766 .unwrap()
6767 .1
6768 .len(),
6769 1
6770 );
6771 storage.advance_clock(1);
6772 assert!(
6773 engine
6774 .lookup_with_txids(&question, None)
6775 .await
6776 .unwrap()
6777 .1
6778 .is_empty(),
6779 "an aged unproven child must be held even when its parent is proven"
6780 );
6781 }
6782
6783 #[tokio::test]
6784 async fn proof_window_peer_proof_verifies_then_restores_lookup() {
6785 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
6786 let mut engine = make_engine(vec![0]);
6787 let storage = std::rc::Rc::new(MemoryStorage::new());
6788 engine.storage = Box::new(storage.clone());
6789 engine
6790 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
6791 .await
6792 .unwrap();
6793 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
6794 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6795 let candidate = HeldOutput {
6796 txid: TEST_TXID.into(),
6797 output_index: 0,
6798 topic: "tm_test".into(),
6799 };
6800 let (_, mut remote) = budget_page_engine(1);
6801 remote.node_cost_ms = 0;
6802 remote.nodes = std::rc::Rc::new(HashMap::from([(
6803 TEST_TXID.into(),
6804 crate::types::GASPNode {
6805 graph_id: String::new(),
6806 raw_tx: String::new(),
6807 output_index: 0,
6808 proof: Some(single_leaf_bump_hex(TEST_TXID, 800_000)),
6809 tx_metadata: None,
6810 output_metadata: None,
6811 inputs: None,
6812 },
6813 )]));
6814 engine
6815 .config
6816 .sync_configuration
6817 .insert("tm_test".into(), SyncTarget::Ship);
6818 engine.ship_bootstrap_peers.insert(
6819 "tm_test".into(),
6820 vec!["https://a.example".into(), "https://b.example".into()],
6821 );
6822 engine.set_gasp_remote_factory(Box::new(remote));
6823 let progress = std::cell::Cell::new(PeerProofSummary::default());
6824 engine.chain_tracker = Some(Box::new(bsv_rs::transaction::MockChainTracker::new(
6825 800_001,
6826 )));
6827 engine
6828 .rerequest_held_proofs(std::slice::from_ref(&candidate), &progress)
6829 .await;
6830 assert_eq!(progress.get().requests, 2, "ask every topic peer");
6831 assert_eq!(progress.get().landed, 0);
6832 assert_eq!(progress.get().refused, 2);
6833 assert!(engine
6834 .lookup_with_txids(&question, None)
6835 .await
6836 .unwrap()
6837 .1
6838 .is_empty());
6839 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
6840 tracker.add_root(800_000, TEST_TXID.into());
6841 engine.chain_tracker = Some(Box::new(tracker));
6842 engine.rerequest_held_proofs(&[candidate], &progress).await;
6843 assert_eq!(progress.get().requests, 4);
6844 assert_eq!(
6845 progress.get().landed,
6846 1,
6847 "count durable proofs, not peer answers"
6848 );
6849 let (_, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
6850 assert_eq!(txids, vec![TEST_TXID]);
6851 let stored = storage
6852 .find_output(TEST_TXID, 0, None, None, true)
6853 .await
6854 .unwrap()
6855 .unwrap();
6856 assert!(
6857 beef_limits::own_proof(stored.beef.as_ref().unwrap(), TEST_TXID)
6858 .unwrap()
6859 .is_some()
6860 );
6861 assert_eq!(storage.proven_at_height(TEST_TXID), Some(Some(800_000)));
6862 }
6863
6864 #[tokio::test]
6865 async fn proof_window_peer_hash_leaf_is_stored_as_own_proof() {
6866 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
6867 let mut engine = make_engine(vec![0]);
6868 let storage = std::rc::Rc::new(MemoryStorage::new());
6869 engine.storage = Box::new(storage.clone());
6870 engine
6871 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
6872 .await
6873 .unwrap();
6874 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
6875 let mut proof =
6876 bsv_rs::transaction::MerklePath::from_hex(&single_leaf_bump_hex(TEST_TXID, 800_000))
6877 .unwrap();
6878 proof.path[0][0].txid = false;
6879 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
6880 tracker.add_root(800_000, proof.compute_root(Some(TEST_TXID)).unwrap());
6881 engine.chain_tracker = Some(Box::new(tracker));
6882 let (_, mut remote) = budget_page_engine(1);
6883 remote.node_cost_ms = 0;
6884 remote.nodes = std::rc::Rc::new(HashMap::from([(
6885 TEST_TXID.into(),
6886 crate::types::GASPNode {
6887 graph_id: String::new(),
6888 raw_tx: String::new(),
6889 output_index: 0,
6890 proof: Some(proof.to_hex()),
6891 tx_metadata: None,
6892 output_metadata: None,
6893 inputs: None,
6894 },
6895 )]));
6896 engine.config.sync_configuration.insert(
6897 "tm_test".into(),
6898 SyncTarget::Peers(vec!["https://peer.example".into()]),
6899 );
6900 engine.set_gasp_remote_factory(Box::new(remote));
6901 let progress = std::cell::Cell::new(PeerProofSummary::default());
6902 engine
6903 .rerequest_held_proofs(
6904 &[HeldOutput {
6905 txid: TEST_TXID.into(),
6906 output_index: 0,
6907 topic: "tm_test".into(),
6908 }],
6909 &progress,
6910 )
6911 .await;
6912 assert_eq!(progress.get().landed, 1);
6913 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6914 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
6915 assert_eq!(txids, vec![TEST_TXID]);
6916 let LookupAnswer::OutputList { outputs } = answer else {
6917 panic!("expected outputs")
6918 };
6919 assert!(
6920 beef_limits::own_proof(&outputs[0].beef, TEST_TXID)
6921 .unwrap()
6922 .is_some(),
6923 "a proven flag must have the child's own path in the served BEEF"
6924 );
6925 }
6926
6927 #[tokio::test]
6928 async fn proof_window_silent_peers_yield_at_the_request_budget() {
6929 use crate::proof_window::{HeldOutput, PeerProofSummary, PEER_PROOF_REQUEST_MS};
6930 let (mut engine, remote) = budget_page_engine(1);
6931 engine.config.sync_configuration.insert(
6932 "tm_test".into(),
6933 SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
6934 );
6935 let elapsed = remote.elapsed.clone();
6936 engine.set_peer_sync_budget(
6937 std::rc::Rc::new(move |ms| {
6938 assert_eq!(ms, PEER_PROOF_REQUEST_MS);
6939 let deadline = elapsed.get() + ms;
6940 let elapsed = elapsed.clone();
6941 Box::pin(std::future::poll_fn(move |_| {
6942 if elapsed.get() >= deadline {
6943 std::task::Poll::Ready(())
6944 } else {
6945 std::task::Poll::Pending
6946 }
6947 }))
6948 }),
6949 30_000,
6950 );
6951 let candidate = HeldOutput {
6952 txid: remote.outputs[0].txid.clone(),
6953 output_index: 0,
6954 topic: "tm_test".into(),
6955 };
6956 engine.set_gasp_remote_factory(Box::new(remote));
6957 let progress = std::cell::Cell::new(PeerProofSummary::default());
6958 engine.rerequest_held_proofs(&[candidate], &progress).await;
6959 assert_eq!(progress.get().requests, 2);
6960 assert_eq!(progress.get().faults, 2);
6961 assert_eq!(progress.get().landed, 0);
6962 }
6963
6964 #[test]
6966 fn sync_limit_clamp() {
6967 assert_eq!(
6968 Engine::clamp_sync_limit(None),
6969 Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
6970 "absent limit gets the default page, never unbounded"
6971 );
6972 assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
6973 assert_eq!(
6974 Engine::clamp_sync_limit(Some(u64::MAX)),
6975 Engine::SYNC_RESPONSE_MAX_LIMIT,
6976 "a huge caller-supplied limit is capped"
6977 );
6978 }
6979
6980 #[tokio::test]
6981 async fn test_lookup_unknown_service_errors() {
6982 let engine = make_engine(vec![]);
6983 let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
6984
6985 let result = engine.lookup(&question, None).await;
6986 assert!(result.is_err());
6987 assert!(matches!(
6988 result.unwrap_err(),
6989 EngineError::LookupServiceNotFound(_)
6990 ));
6991 }
6992
6993 #[tokio::test]
6994 async fn test_list_topic_managers() {
6995 let engine = make_engine(vec![]);
6996 let managers = engine.list_topic_managers().await;
6997
6998 assert_eq!(managers.len(), 1);
6999 assert!(managers.contains_key("tm_test"));
7000 assert_eq!(managers["tm_test"].name, "mock-tm");
7001 }
7002
7003 #[tokio::test]
7004 async fn test_list_lookup_service_providers() {
7005 let engine = make_engine(vec![]);
7006 let services = engine.list_lookup_service_providers().await;
7007
7008 assert_eq!(services.len(), 1);
7009 assert!(services.contains_key("ls_test"));
7010 assert_eq!(services["ls_test"].name, "mock-ls");
7011 }
7012
7013 #[tokio::test]
7014 async fn test_get_documentation() {
7015 let engine = make_engine(vec![]);
7016
7017 let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
7018 assert_eq!(tm_docs, "Mock topic manager");
7019
7020 let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
7021 assert_eq!(ls_docs, "Mock lookup service");
7022
7023 let missing = engine
7024 .get_documentation_for_topic_manager("tm_missing")
7025 .await;
7026 assert_eq!(missing, "No documentation found!");
7027 }
7028
7029 #[tokio::test]
7030 async fn test_provide_foreign_sync_response() {
7031 let engine = make_engine(vec![0]);
7032
7033 let beef = test_tagged_beef(vec!["tm_test"]);
7035 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7036
7037 let request = GASPInitialRequest {
7039 version: 1,
7040 since: 0,
7041 limit: Some(100),
7042 };
7043 let response = engine
7044 .provide_foreign_sync_response(&request, "tm_test")
7045 .await
7046 .unwrap();
7047
7048 assert_eq!(response.utxo_list.len(), 1);
7050 assert_eq!(response.utxo_list[0].txid, TEST_TXID);
7051 assert_eq!(response.since, 0);
7052 }
7053
7054 #[tokio::test]
7055 async fn test_delete_utxo_deep_simple() {
7056 let engine = make_engine(vec![0]);
7057 let beef = test_tagged_beef(vec!["tm_test"]);
7058
7059 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7060
7061 let txid = TEST_TXID;
7062 let output = engine
7063 .storage()
7064 .find_output(txid, 0, Some("tm_test"), None, false)
7065 .await
7066 .unwrap()
7067 .unwrap();
7068
7069 engine.delete_utxo_deep(&output, None).await.unwrap();
7071
7072 let found = engine
7074 .storage()
7075 .find_output(txid, 0, Some("tm_test"), None, false)
7076 .await
7077 .unwrap();
7078 assert!(found.is_none());
7079 }
7080
7081 #[tokio::test]
7082 async fn test_sync_config_defaults_to_ship() {
7083 let engine = make_engine(vec![]);
7084 let sync = &engine.config.sync_configuration;
7085
7086 assert!(sync.contains_key("tm_test"));
7088 assert!(matches!(sync["tm_test"], SyncTarget::Ship));
7089 }
7090
7091 #[tokio::test]
7092 async fn test_multiple_topics_in_single_submit() {
7093 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7094 managers.insert(
7095 "tm_alpha".to_string(),
7096 Box::new(MockTopicManager::admitting(vec![0])),
7097 );
7098 managers.insert(
7099 "tm_beta".to_string(),
7100 Box::new(MockTopicManager::admitting(vec![1])),
7101 );
7102
7103 let engine = Engine::new(
7104 managers,
7105 HashMap::new(),
7106 Box::new(MemoryStorage::new()),
7107 None,
7108 EngineConfig::default(),
7109 );
7110
7111 let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
7112
7113 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7114
7115 assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
7116 assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
7117 }
7118
7119 #[tokio::test]
7120 async fn test_evict_output_with_topic() {
7121 let engine = make_engine(vec![0]);
7122 let beef = test_tagged_beef(vec!["tm_test"]);
7123 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7124
7125 let found = engine
7127 .storage()
7128 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7129 .await
7130 .unwrap();
7131 assert!(found.is_some(), "Output should exist before eviction");
7132
7133 engine
7135 .evict_output(TEST_TXID, 0, Some("tm_test"))
7136 .await
7137 .unwrap();
7138
7139 let found = engine
7141 .storage()
7142 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7143 .await
7144 .unwrap();
7145 assert!(found.is_none(), "Output should be gone after eviction");
7146
7147 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7149 let answer = engine.lookup(&question, None).await.unwrap();
7150 match answer {
7151 LookupAnswer::OutputList { outputs } => {
7152 assert!(
7153 outputs.is_empty(),
7154 "Lookup should return empty after eviction"
7155 );
7156 }
7157 _ => panic!("Expected OutputList"),
7158 }
7159 }
7160
7161 #[tokio::test]
7162 async fn test_evict_output_without_topic() {
7163 let engine = make_engine(vec![0]);
7164 let beef = test_tagged_beef(vec!["tm_test"]);
7165 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7166
7167 engine.evict_output(TEST_TXID, 0, None).await.unwrap();
7169
7170 let found = engine
7171 .storage()
7172 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7173 .await
7174 .unwrap();
7175 assert!(
7176 found.is_none(),
7177 "Output should be gone after topic-less eviction"
7178 );
7179 }
7180
7181 #[tokio::test]
7182 async fn test_evict_nonexistent_output_is_ok() {
7183 let engine = make_engine(vec![]);
7184
7185 let result = engine
7187 .evict_output("nonexistent_txid", 99, Some("tm_test"))
7188 .await;
7189 assert!(result.is_ok(), "Evicting nonexistent output should succeed");
7190 }
7191
7192 use crate::broadcaster::Broadcaster;
7195 use std::sync::Arc;
7196
7197 type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
7198
7199 struct MockBroadcaster {
7200 calls: BroadcastCallLog,
7201 }
7202
7203 impl MockBroadcaster {
7204 fn new() -> Self {
7205 Self {
7206 calls: Arc::new(Mutex::new(Vec::new())),
7207 }
7208 }
7209
7210 #[allow(dead_code, reason = "kept for future test extension")]
7211 fn call_count(&self) -> usize {
7212 self.calls.lock().unwrap().len()
7213 }
7214
7215 #[allow(dead_code, reason = "kept for future test extension")]
7216 fn calls(&self) -> Vec<(String, Vec<String>)> {
7217 self.calls.lock().unwrap().clone()
7218 }
7219 }
7220
7221 #[async_trait(?Send)]
7222 impl Broadcaster for MockBroadcaster {
7223 async fn broadcast_to_host(
7224 &self,
7225 host_url: &str,
7226 tagged_beef: &TaggedBEEF,
7227 ) -> Result<(), String> {
7228 self.calls
7229 .lock()
7230 .unwrap()
7231 .push((host_url.to_string(), tagged_beef.topics.clone()));
7232 Ok(())
7233 }
7234 }
7235
7236 struct MockSHIPLookupService {
7242 records: Mutex<HashMap<String, Vec<UTXOReference>>>,
7244 }
7245
7246 impl MockSHIPLookupService {
7247 fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
7248 Self {
7249 records: Mutex::new(records),
7250 }
7251 }
7252 }
7253
7254 #[async_trait(?Send)]
7255 impl LookupServiceTrait for MockSHIPLookupService {
7256 fn admission_mode(&self) -> AdmissionMode {
7257 AdmissionMode::LockingScript
7258 }
7259
7260 fn spend_notification_mode(&self) -> SpendNotificationMode {
7261 SpendNotificationMode::None
7262 }
7263
7264 async fn output_admitted_by_topic(
7265 &self,
7266 _payload: &OutputAdmittedByTopic,
7267 ) -> Result<(), LookupServiceError> {
7268 Ok(())
7269 }
7270
7271 async fn output_evicted(
7272 &self,
7273 _txid: &str,
7274 _output_index: u32,
7275 ) -> Result<(), LookupServiceError> {
7276 Ok(())
7277 }
7278
7279 async fn lookup(
7280 &self,
7281 question: &LookupQuestion,
7282 ) -> Result<LookupResult, LookupServiceError> {
7283 if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
7285 let records = self.records.lock().unwrap();
7286 let mut results = Vec::new();
7287 for topic_val in topics {
7288 if let Some(topic) = topic_val.as_str() {
7289 if let Some(refs) = records.get(topic) {
7290 results.extend(refs.iter().cloned());
7291 }
7292 }
7293 }
7294 return Ok(LookupResult::OutputList(results));
7295 }
7296 Ok(LookupResult::OutputList(vec![]))
7297 }
7298
7299 async fn get_documentation(&self) -> String {
7300 "Mock SHIP lookup service".to_string()
7301 }
7302
7303 async fn get_metadata(&self) -> ServiceMetadata {
7304 ServiceMetadata {
7305 name: "mock-ls-ship".to_string(),
7306 ..Default::default()
7307 }
7308 }
7309 }
7310
7311 fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
7313 use bsv_rs::script::templates::PushDrop;
7314 use bsv_rs::PublicKey;
7315
7316 let fields = vec![
7317 b"SHIP".to_vec(),
7318 vec![0x02; 33], domain.as_bytes().to_vec(),
7320 topic.as_bytes().to_vec(),
7321 ];
7322
7323 let pubkey = PublicKey::from_hex(
7326 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7327 )
7328 .expect("valid test pubkey");
7329
7330 let pd = PushDrop::new(pubkey, fields);
7331 pd.lock().to_binary()
7332 }
7333
7334 async fn make_engine_with_broadcaster(
7337 admit_indices: Vec<u32>,
7338 broadcaster: MockBroadcaster,
7339 ship_domains: Vec<(&str, &str)>,
7340 hosting_url: Option<&str>,
7341 ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
7342 let calls = broadcaster.calls.clone();
7343
7344 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7345 managers.insert(
7346 "tm_test".to_string(),
7347 Box::new(MockTopicManager::admitting(admit_indices)),
7348 );
7349
7350 let storage = Box::new(MemoryStorage::new());
7351
7352 let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
7356
7357 for (domain, topic) in &ship_domains {
7358 let fake_txid = format!("{:064x}", {
7360 let mut h: u64 = 0;
7361 for b in domain.bytes().chain(topic.bytes()) {
7362 h = h.wrapping_mul(31).wrapping_add(u64::from(b));
7363 }
7364 h
7365 });
7366
7367 ship_records
7369 .entry(topic.to_string())
7370 .or_default()
7371 .push(UTXOReference {
7372 txid: fake_txid.clone(),
7373 output_index: 0,
7374 });
7375
7376 let script = build_ship_pushdrop_script(domain, topic);
7378
7379 let output = Output {
7380 txid: fake_txid,
7381 output_index: 0,
7382 output_script: script,
7383 satoshis: 1,
7384 topic: "tm_ship".to_string(),
7385 spent: false,
7386 outputs_consumed: vec![],
7387 consumed_by: vec![],
7388 beef: None,
7389 block_height: None,
7390 score: None,
7391 };
7392
7393 storage.insert_output(&output).await.unwrap();
7394 }
7395
7396 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7397 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7398 lookup_services.insert(
7399 "ls_ship".to_string(),
7400 Box::new(MockSHIPLookupService::with_records(ship_records)),
7401 );
7402
7403 let config = EngineConfig {
7404 hosting_url: hosting_url.map(str::to_string),
7405 ..Default::default()
7406 };
7407
7408 let engine = Engine::with_chain_tracker(
7409 managers,
7410 lookup_services,
7411 storage,
7412 None,
7413 Some(Box::new(broadcaster)),
7414 None,
7415 config,
7416 );
7417
7418 (engine, calls)
7419 }
7420
7421 #[tokio::test]
7424 async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
7425 let broadcaster = MockBroadcaster::new();
7426 let (engine, calls) = make_engine_with_broadcaster(
7427 vec![0],
7428 broadcaster,
7429 vec![("https://peer1.example.com", "tm_test")],
7430 Some("https://self.example.com"),
7431 )
7432 .await;
7433
7434 let beef = test_tagged_beef(vec!["tm_test"]);
7435 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7436
7437 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7439
7440 let recorded = calls.lock().unwrap();
7442 assert_eq!(
7443 recorded.len(),
7444 1,
7445 "Broadcaster should be called once for the one SHIP peer"
7446 );
7447 assert_eq!(recorded[0].0, "https://peer1.example.com");
7448 }
7449
7450 #[tokio::test]
7451 async fn test_broadcaster_not_called_on_historical_tx() {
7452 let broadcaster = MockBroadcaster::new();
7453 let (engine, calls) = make_engine_with_broadcaster(
7454 vec![0],
7455 broadcaster,
7456 vec![("https://peer1.example.com", "tm_test")],
7457 None,
7458 )
7459 .await;
7460
7461 let beef = test_tagged_beef(vec!["tm_test"]);
7462 let steak = engine
7463 .submit(&beef, SubmitMode::HistoricalTx)
7464 .await
7465 .unwrap();
7466
7467 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7468
7469 assert_eq!(
7471 calls.lock().unwrap().len(),
7472 0,
7473 "Broadcaster should not be called for historical TX"
7474 );
7475 }
7476
7477 #[tokio::test]
7478 async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
7479 let broadcaster = MockBroadcaster::new();
7480 let (engine, calls) = make_engine_with_broadcaster(
7481 vec![0],
7482 broadcaster,
7483 vec![("https://peer1.example.com", "tm_test")],
7484 None,
7485 )
7486 .await;
7487
7488 let beef = test_tagged_beef(vec!["tm_test"]);
7489 let steak = engine
7490 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
7491 .await
7492 .unwrap();
7493
7494 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7495
7496 assert_eq!(
7497 calls.lock().unwrap().len(),
7498 0,
7499 "Broadcaster should not be called for historical-tx-no-spv"
7500 );
7501 }
7502
7503 #[tokio::test]
7504 async fn test_broadcaster_skips_self_hosting_url() {
7505 let broadcaster = MockBroadcaster::new();
7506 let (engine, calls) = make_engine_with_broadcaster(
7507 vec![0],
7508 broadcaster,
7509 vec![
7510 ("https://self.example.com", "tm_test"),
7511 ("https://peer2.example.com", "tm_test"),
7512 ],
7513 Some("https://self.example.com"),
7514 )
7515 .await;
7516
7517 let beef = test_tagged_beef(vec!["tm_test"]);
7518 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7519
7520 let recorded = calls.lock().unwrap();
7522 assert_eq!(
7523 recorded.len(),
7524 1,
7525 "Should broadcast to peer2 only, skipping self"
7526 );
7527 assert_eq!(recorded[0].0, "https://peer2.example.com");
7528 }
7529
7530 #[tokio::test]
7531 async fn test_broadcaster_not_called_when_no_outputs_admitted() {
7532 let broadcaster = MockBroadcaster::new();
7533 let (engine, calls) = make_engine_with_broadcaster(
7535 vec![],
7536 broadcaster,
7537 vec![("https://peer1.example.com", "tm_test")],
7538 None,
7539 )
7540 .await;
7541
7542 let beef = test_tagged_beef(vec!["tm_test"]);
7543 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7544
7545 assert!(steak["tm_test"].outputs_to_admit.is_empty());
7546
7547 assert_eq!(
7549 calls.lock().unwrap().len(),
7550 0,
7551 "Broadcaster should not be called when no outputs are admitted"
7552 );
7553 }
7554
7555 #[tokio::test]
7556 async fn test_broadcaster_not_called_when_no_ship_peers() {
7557 let broadcaster = MockBroadcaster::new();
7558 let (engine, calls) = make_engine_with_broadcaster(
7560 vec![0],
7561 broadcaster,
7562 vec![], None,
7564 )
7565 .await;
7566
7567 let beef = test_tagged_beef(vec!["tm_test"]);
7568 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7569
7570 assert_eq!(
7571 calls.lock().unwrap().len(),
7572 0,
7573 "Broadcaster should not be called when no SHIP peers exist"
7574 );
7575 }
7576
7577 struct SpendModeLookupService {
7583 mode: SpendNotificationMode,
7584 records: Mutex<Vec<UTXOReference>>,
7585 spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
7586 }
7587
7588 impl SpendModeLookupService {
7589 fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
7590 Self {
7591 mode,
7592 records: Mutex::new(Vec::new()),
7593 spent_payloads: capture,
7594 }
7595 }
7596 }
7597
7598 #[async_trait(?Send)]
7599 impl LookupServiceTrait for SpendModeLookupService {
7600 fn admission_mode(&self) -> AdmissionMode {
7601 AdmissionMode::LockingScript
7602 }
7603
7604 fn spend_notification_mode(&self) -> SpendNotificationMode {
7605 self.mode
7606 }
7607
7608 async fn output_admitted_by_topic(
7609 &self,
7610 payload: &OutputAdmittedByTopic,
7611 ) -> Result<(), LookupServiceError> {
7612 let (txid, oi) = match payload {
7613 OutputAdmittedByTopic::LockingScript {
7614 txid, output_index, ..
7615 } => (txid.clone(), *output_index),
7616 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
7617 ("whole".into(), *output_index)
7618 }
7619 };
7620 self.records.lock().unwrap().push(UTXOReference {
7621 txid,
7622 output_index: oi,
7623 });
7624 Ok(())
7625 }
7626
7627 async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
7628 self.spent_payloads.lock().unwrap().push(payload.clone());
7629 Ok(())
7630 }
7631
7632 async fn output_evicted(
7633 &self,
7634 txid: &str,
7635 output_index: u32,
7636 ) -> Result<(), LookupServiceError> {
7637 self.records
7638 .lock()
7639 .unwrap()
7640 .retain(|r| !(r.txid == txid && r.output_index == output_index));
7641 Ok(())
7642 }
7643
7644 async fn lookup(
7645 &self,
7646 _question: &LookupQuestion,
7647 ) -> Result<LookupResult, LookupServiceError> {
7648 Ok(LookupResult::OutputList(
7649 self.records.lock().unwrap().clone(),
7650 ))
7651 }
7652
7653 async fn get_documentation(&self) -> String {
7654 "SpendMode mock lookup service".to_string()
7655 }
7656
7657 async fn get_metadata(&self) -> ServiceMetadata {
7658 ServiceMetadata {
7659 name: "spend-mode-ls".to_string(),
7660 ..Default::default()
7661 }
7662 }
7663 }
7664
7665 const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
7667
7668 async fn make_spend_mode_engine(
7671 mode: SpendNotificationMode,
7672 ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
7673 let storage = MemoryStorage::new();
7674
7675 let prev_output = Output {
7677 txid: PREVIOUS_TXID.to_string(),
7678 output_index: 0,
7679 output_script: vec![0x76, 0xa9],
7680 satoshis: 26174,
7681 topic: "tm_test".to_string(),
7682 spent: false,
7683 outputs_consumed: vec![],
7684 consumed_by: vec![],
7685 beef: Some(test_beef()),
7686 block_height: None,
7687 score: Some(1000.0),
7688 };
7689 storage.insert_output(&prev_output).await.unwrap();
7690
7691 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7692 managers.insert(
7693 "tm_test".to_string(),
7694 Box::new(MockTopicManager::admitting(vec![0])),
7695 );
7696
7697 let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
7698 let ls = SpendModeLookupService::with_mode(mode, capture.clone());
7699 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7700 lookup_services.insert("ls_test".to_string(), Box::new(ls));
7701
7702 let engine = Engine::new(
7703 managers,
7704 lookup_services,
7705 Box::new(storage),
7706 None,
7707 EngineConfig::default(),
7708 );
7709
7710 (engine, capture)
7711 }
7712
7713 #[tokio::test]
7714 async fn test_spend_notification_mode_script() {
7715 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
7716
7717 let beef = test_tagged_beef(vec!["tm_test"]);
7718 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7719
7720 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7722
7723 let payloads = capture.lock().unwrap().clone();
7725 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7726
7727 match &payloads[0] {
7728 OutputSpent::Script {
7729 txid,
7730 output_index,
7731 topic,
7732 spending_txid,
7733 input_index,
7734 unlocking_script,
7735 sequence_number,
7736 ..
7737 } => {
7738 assert_eq!(txid, PREVIOUS_TXID);
7739 assert_eq!(*output_index, 0);
7740 assert_eq!(topic, "tm_test");
7741 assert_eq!(spending_txid, TEST_TXID);
7742 assert_eq!(*input_index, 0);
7744 assert!(
7746 !unlocking_script.is_empty(),
7747 "Unlocking script should be non-empty"
7748 );
7749 assert_eq!(*sequence_number, 0xffff_ffff);
7751 }
7752 other => panic!("Expected OutputSpent::Script, got: {other:?}"),
7753 }
7754 }
7755
7756 #[tokio::test]
7757 async fn test_spend_notification_mode_whole_tx() {
7758 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
7759
7760 let beef = test_tagged_beef(vec!["tm_test"]);
7761 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7762
7763 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7764
7765 let payloads = capture.lock().unwrap().clone();
7766 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7767
7768 match &payloads[0] {
7769 OutputSpent::WholeTx {
7770 txid,
7771 output_index,
7772 topic,
7773 spending_atomic_beef,
7774 ..
7775 } => {
7776 assert_eq!(txid, PREVIOUS_TXID);
7777 assert_eq!(*output_index, 0);
7778 assert_eq!(topic, "tm_test");
7779 let mut named =
7782 bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
7783 assert!(named.is_atomic());
7784 let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
7785 assert_eq!(
7786 named.txs.len(),
7787 submitted.txs.len(),
7788 "the whole body, never pruned"
7789 );
7790 let tip = crate::subject::subject_txid_of(&mut named).unwrap();
7791 assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
7792 }
7793 other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
7794 }
7795 }
7796
7797 #[tokio::test]
7798 async fn test_spend_notification_mode_txid() {
7799 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
7800
7801 let beef = test_tagged_beef(vec!["tm_test"]);
7802 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7803
7804 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7805
7806 let payloads = capture.lock().unwrap().clone();
7807 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7808
7809 match &payloads[0] {
7810 OutputSpent::Txid {
7811 txid,
7812 output_index,
7813 topic,
7814 spending_txid,
7815 } => {
7816 assert_eq!(txid, PREVIOUS_TXID);
7817 assert_eq!(*output_index, 0);
7818 assert_eq!(topic, "tm_test");
7819 assert_eq!(spending_txid, TEST_TXID);
7820 }
7821 other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
7822 }
7823 }
7824
7825 #[tokio::test]
7826 async fn test_spend_notification_mode_none() {
7827 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
7828
7829 let beef = test_tagged_beef(vec!["tm_test"]);
7830 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7831
7832 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7833
7834 let payloads = capture.lock().unwrap().clone();
7835 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7836
7837 match &payloads[0] {
7838 OutputSpent::None {
7839 txid,
7840 output_index,
7841 topic,
7842 } => {
7843 assert_eq!(txid, PREVIOUS_TXID);
7844 assert_eq!(*output_index, 0);
7845 assert_eq!(topic, "tm_test");
7846 }
7847 other => panic!("Expected OutputSpent::None, got: {other:?}"),
7848 }
7849 }
7850
7851 #[tokio::test]
7854 async fn test_start_gasp_sync_empty_config() {
7855 let engine = Engine::new(
7856 HashMap::new(),
7857 HashMap::new(),
7858 Box::new(MemoryStorage::new()),
7859 None,
7860 EngineConfig {
7861 sync_configuration: HashMap::new(),
7862 ..Default::default()
7863 },
7864 );
7865
7866 let result = engine.start_gasp_sync().await.unwrap();
7867 assert!(
7868 result.topics_synced.is_empty(),
7869 "No topics should be synced with empty config"
7870 );
7871 }
7872
7873 #[tokio::test]
7874 async fn test_start_gasp_sync_disabled_topic_skipped() {
7875 let mut sync_config: SyncConfiguration = HashMap::new();
7876 sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
7877
7878 let engine = Engine::new(
7879 HashMap::new(),
7880 HashMap::new(),
7881 Box::new(MemoryStorage::new()),
7882 None,
7883 EngineConfig {
7884 sync_configuration: sync_config,
7885 ..Default::default()
7886 },
7887 );
7888
7889 let result = engine.start_gasp_sync().await.unwrap();
7890 assert!(
7891 result.topics_synced.is_empty(),
7892 "Disabled topics should not appear in results"
7893 );
7894 }
7895
7896 #[tokio::test]
7897 async fn test_start_gasp_sync_peers_config() {
7898 let mut sync_config: SyncConfiguration = HashMap::new();
7899 sync_config.insert(
7900 "tm_test".to_string(),
7901 SyncTarget::Peers(vec![
7902 "https://peer1.example.com".to_string(),
7903 "https://peer2.example.com".to_string(),
7904 ]),
7905 );
7906
7907 let engine = Engine::new(
7908 HashMap::new(),
7909 HashMap::new(),
7910 Box::new(MemoryStorage::new()),
7911 None,
7912 EngineConfig {
7913 sync_configuration: sync_config,
7914 ..Default::default()
7915 },
7916 );
7917
7918 let result = engine.start_gasp_sync().await.unwrap();
7919 assert_eq!(result.topics_synced.len(), 1);
7920
7921 let topic_result = &result.topics_synced["tm_test"];
7922 assert_eq!(topic_result.peers.len(), 2);
7923 assert_eq!(topic_result.sync_type, "peers");
7924 assert!(topic_result.errors.is_empty());
7925 }
7926
7927 #[tokio::test]
7928 async fn test_start_gasp_sync_peers_filters_self() {
7929 let mut sync_config: SyncConfiguration = HashMap::new();
7930 sync_config.insert(
7931 "tm_test".to_string(),
7932 SyncTarget::Peers(vec![
7933 "https://self.example.com".to_string(),
7934 "https://peer2.example.com".to_string(),
7935 ]),
7936 );
7937
7938 let engine = Engine::new(
7939 HashMap::new(),
7940 HashMap::new(),
7941 Box::new(MemoryStorage::new()),
7942 None,
7943 EngineConfig {
7944 hosting_url: Some("https://self.example.com".to_string()),
7945 sync_configuration: sync_config,
7946 ..Default::default()
7947 },
7948 );
7949
7950 let result = engine.start_gasp_sync().await.unwrap();
7951 let topic_result = &result.topics_synced["tm_test"];
7952 assert_eq!(
7953 topic_result.peers.len(),
7954 1,
7955 "Self URL should be filtered out"
7956 );
7957 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
7958 }
7959
7960 #[derive(Clone, Default)]
7961 struct MockShipLookupClient {
7962 records: HashMap<(String, String), Vec<OutputListItem>>,
7963 failures: HashSet<String>,
7964 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
7965 }
7966
7967 struct RecordingDiscoverySyncFactory {
7968 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
7969 }
7970
7971 impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
7972 fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
7973 self.calls
7974 .borrow_mut()
7975 .push((peer.to_string(), topic.to_string()));
7976 Box::new(MockSyncRemote { utxos: Vec::new() })
7977 }
7978 }
7979
7980 #[async_trait(?Send)]
7981 impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
7982 async fn lookup(
7983 &self,
7984 host: &str,
7985 question: &LookupQuestion,
7986 ) -> Result<Vec<OutputListItem>, String> {
7987 self.calls
7988 .borrow_mut()
7989 .push((host.to_string(), question.clone()));
7990 if self.failures.contains(host) {
7991 return Err("mock tracker unavailable".to_string());
7992 }
7993 Ok(self
7994 .records
7995 .get(&(host.to_string(), question.service.clone()))
7996 .cloned()
7997 .unwrap_or_default())
7998 }
7999 }
8000
8001 fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
8002 use bsv_rs::script::templates::PushDrop;
8003 let pubkey = bsv_rs::PublicKey::from_hex(
8004 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
8005 )
8006 .unwrap();
8007 let script = PushDrop::new(
8008 pubkey,
8009 vec![
8010 protocol.as_bytes().to_vec(),
8011 vec![0x02; 33],
8012 domain.as_bytes().to_vec(),
8013 scope.as_bytes().to_vec(),
8014 ],
8015 )
8016 .lock();
8017 let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
8018 tx.outputs[0].locking_script = script;
8019 tx.merkle_path = None;
8020 tx.invalidate_caches();
8021 let beef = tx.to_atomic_beef(true).unwrap();
8022 let parsed =
8023 beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
8024 .unwrap();
8025 assert_eq!(
8026 parsed.outputs[0].locking_script,
8027 tx.outputs[0].locking_script
8028 );
8029 OutputListItem {
8030 beef,
8031 output_index: 0,
8032 context: None,
8033 }
8034 }
8035
8036 #[tokio::test]
8037 async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
8038 let (mut engine, _) = make_engine_with_broadcaster(
8039 vec![0],
8040 MockBroadcaster::new(),
8041 vec![
8042 ("https://local.example", "tm_test"),
8043 ("https://shared.example/?local", "tm_test"),
8044 ],
8045 Some("https://self.example"),
8046 )
8047 .await;
8048 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8049 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8050 let mut client = MockShipLookupClient::default();
8051 client.records.insert(
8052 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8053 vec![
8054 discovery_output("SHIP", "https://direct.example", "tm_test"),
8055 discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
8056 discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
8057 discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
8058 discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
8059 OutputListItem {
8060 beef: vec![1, 2, 3],
8061 output_index: 0,
8062 context: None,
8063 },
8064 ],
8065 );
8066 client.records.insert(
8067 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8068 vec![
8069 discovery_output("SLAP", "https://lookup.example", "ls_ship"),
8070 discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
8071 discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
8072 discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
8073 discovery_output("SLAP", "https://other-service.example", "ls_other"),
8074 ],
8075 );
8076 client.records.insert(
8077 ("https://lookup.example".to_string(), "ls_ship".to_string()),
8078 vec![
8079 discovery_output("SHIP", "https://indirect.example", "tm_test"),
8080 discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
8081 ],
8082 );
8083 let calls = client.calls.clone();
8084 engine.set_ship_lookup_client(Box::new(client));
8085 let result = engine.start_gasp_sync().await.unwrap();
8086 assert_eq!(
8087 result.topics_synced["tm_test"].peers,
8088 vec![
8089 "https://bootstrap.example",
8090 "https://direct.example",
8091 "https://indirect.example",
8092 "https://local.example",
8093 "https://shared.example",
8094 ]
8095 );
8096 let calls = calls.borrow();
8097 assert_eq!(calls.len(), 3);
8098 for (_, question) in calls.iter() {
8099 let expected = if question.service == "ls_slap" {
8100 serde_json::json!({"service": "ls_ship"})
8101 } else {
8102 serde_json::json!({"topics": ["tm_test"]})
8103 };
8104 assert_eq!(question.query, expected);
8105 }
8106 }
8107
8108 #[tokio::test]
8109 async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
8110 let (mut engine, _) =
8111 make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
8112 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8113 let mut client = MockShipLookupClient::default();
8114 let outputs = (0..9)
8115 .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
8116 .collect();
8117 client.records.insert(
8118 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8119 outputs,
8120 );
8121 engine.set_ship_lookup_client(Box::new(client));
8122 let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8123 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8124 calls: sync_calls.clone(),
8125 }));
8126 let result = engine.start_gasp_sync().await.unwrap();
8127 let topic = &result.topics_synced["tm_test"];
8128 assert_eq!(topic.sync_type, "ship");
8129 assert_eq!(
8130 topic.peers.len(),
8131 9,
8132 "the lookup-host cap must not truncate sync peers"
8133 );
8134 assert!(topic.errors.is_empty());
8135 assert_eq!(sync_calls.borrow().len(), 9);
8136 for peer in &topic.peers {
8137 assert!(sync_calls
8138 .borrow()
8139 .contains(&(peer.clone(), "tm_test".to_string())));
8140 }
8141 }
8142
8143 #[tokio::test]
8144 async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
8145 let mut engine = make_engine(vec![]);
8146 engine.config.slap_trackers = vec![
8147 "https://dead.example".to_string(),
8148 "https://live.example".to_string(),
8149 ];
8150 let mut client = MockShipLookupClient::default();
8151 client.failures.insert("https://dead.example".to_string());
8152 client.records.insert(
8153 ("https://live.example".to_string(), "ls_ship".to_string()),
8154 vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
8155 );
8156 engine.set_ship_lookup_client(Box::new(client));
8157 let result = engine.start_gasp_sync().await.unwrap();
8158 assert_eq!(
8159 result.topics_synced["tm_test"].peers,
8160 vec!["https://peer.example"]
8161 );
8162 }
8163
8164 #[tokio::test]
8165 async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
8166 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
8167 let mut engine = make_engine(vec![]);
8168 engine
8169 .config
8170 .sync_configuration
8171 .insert("tm_other".to_string(), SyncTarget::Ship);
8172 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8173 let mut client = MockShipLookupClient::default();
8174 let mut providers = Vec::new();
8175 for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
8176 let host = format!("https://lookup{n}.example");
8177 providers.push(discovery_output("SLAP", &host, "ls_ship"));
8178 client.records.insert(
8179 (host, "ls_ship".to_string()),
8180 vec![
8181 discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
8182 discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
8183 ],
8184 );
8185 }
8186 client.records.insert(
8187 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8188 providers,
8189 );
8190 let calls = client.calls.clone();
8191 engine.set_ship_lookup_client(Box::new(client));
8192 let result = engine.start_gasp_sync().await.unwrap();
8193 assert_eq!(
8194 result.topics_synced["tm_test"].peers.len(),
8195 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8196 );
8197 assert_eq!(
8198 result.topics_synced["tm_other"].peers.len(),
8199 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8200 );
8201 let calls = calls.borrow();
8202 assert_eq!(
8203 calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
8204 1
8205 );
8206 let providers: HashSet<_> = calls
8207 .iter()
8208 .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
8209 .map(|(h, _)| h)
8210 .collect();
8211 assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
8212 assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
8213 }
8214
8215 #[tokio::test]
8216 async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
8217 let mut engine = make_engine(vec![]);
8218 engine.config.hosting_url = Some("https://self.example".to_string());
8219 engine.config.slap_trackers = vec!["https://dead.example".to_string()];
8220 let mut client = MockShipLookupClient::default();
8221 client.failures.insert("https://dead.example".to_string());
8222 engine.set_ship_lookup_client(Box::new(client));
8223 engine.set_ship_bootstrap_peers(
8224 "tm_test",
8225 vec![
8226 "https://bootstrap.example".to_string(),
8227 "https://BOOTSTRAP.example.:443/?x".to_string(),
8228 "http://SELF.example.:8443/path".to_string(),
8229 ],
8230 );
8231 let result = engine.start_gasp_sync().await.unwrap();
8232 assert_eq!(
8233 result.topics_synced["tm_test"].peers,
8234 vec!["https://bootstrap.example"]
8235 );
8236 }
8237
8238 #[tokio::test]
8239 async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
8240 let mut engine = make_engine(vec![]);
8241 for topic in ["tm_ship", "tm_slap"] {
8242 engine.config.sync_configuration.insert(
8243 topic.to_string(),
8244 SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
8245 );
8246 }
8247 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8248 let mut client = MockShipLookupClient::default();
8249 client.records.insert(
8250 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8251 vec![discovery_output(
8252 "SHIP",
8253 "https://discovered.example",
8254 "tm_test",
8255 )],
8256 );
8257 let lookups = client.calls.clone();
8258 engine.set_ship_lookup_client(Box::new(client));
8259 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8260 let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8261 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8262 calls: calls.clone(),
8263 }));
8264 engine.start_gasp_sync().await.unwrap();
8265 assert_eq!(
8266 calls
8267 .borrow()
8268 .iter()
8269 .map(|(_, topic)| topic.as_str())
8270 .collect::<Vec<_>>(),
8271 vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
8272 );
8273 assert_eq!(lookups.borrow().len(), 2);
8274 }
8275
8276 #[tokio::test]
8277 async fn test_start_gasp_sync_ship_discovers_peers() {
8278 let broadcaster = MockBroadcaster::new();
8279 let (engine, _calls) = make_engine_with_broadcaster(
8280 vec![0],
8281 broadcaster,
8282 vec![
8283 ("https://peer1.example.com", "tm_test"),
8284 ("https://peer2.example.com", "tm_test"),
8285 ],
8286 Some("https://self.example.com"),
8287 )
8288 .await;
8289
8290 let result = engine.start_gasp_sync().await.unwrap();
8291 assert!(
8292 result.topics_synced.contains_key("tm_test"),
8293 "tm_test should be in sync results"
8294 );
8295
8296 let topic_result = &result.topics_synced["tm_test"];
8297 assert_eq!(topic_result.sync_type, "ship");
8298 assert_eq!(
8299 topic_result.peers.len(),
8300 2,
8301 "Should discover both SHIP peers"
8302 );
8303 assert!(topic_result
8304 .peers
8305 .contains(&"https://peer1.example.com".to_string()));
8306 assert!(topic_result
8307 .peers
8308 .contains(&"https://peer2.example.com".to_string()));
8309 }
8310
8311 #[tokio::test]
8312 async fn test_start_gasp_sync_ship_filters_self() {
8313 let broadcaster = MockBroadcaster::new();
8314 let (engine, _calls) = make_engine_with_broadcaster(
8315 vec![0],
8316 broadcaster,
8317 vec![
8318 ("https://self.example.com", "tm_test"),
8319 ("https://peer2.example.com", "tm_test"),
8320 ],
8321 Some("https://self.example.com"),
8322 )
8323 .await;
8324
8325 let result = engine.start_gasp_sync().await.unwrap();
8326 let topic_result = &result.topics_synced["tm_test"];
8327 assert_eq!(
8328 topic_result.peers.len(),
8329 1,
8330 "Self URL should be filtered out from SHIP discovery"
8331 );
8332 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
8333 }
8334
8335 #[tokio::test]
8336 async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
8337 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8338 managers.insert(
8339 "tm_custom".to_string(),
8340 Box::new(MockTopicManager::admitting(vec![])),
8341 );
8342
8343 let mut sync_config: SyncConfiguration = HashMap::new();
8344 sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
8345
8346 let engine = Engine::new(
8347 managers,
8348 HashMap::new(),
8349 Box::new(MemoryStorage::new()),
8350 None,
8351 EngineConfig {
8352 sync_configuration: sync_config,
8353 ..Default::default()
8354 },
8355 );
8356
8357 let result = engine.start_gasp_sync().await.unwrap();
8358 let topic_result = &result.topics_synced["tm_custom"];
8359 assert!(
8360 topic_result.peers.is_empty(),
8361 "Without ls_ship, SHIP discovery should return no peers"
8362 );
8363 }
8364
8365 #[tokio::test]
8366 async fn test_start_gasp_sync_mixed_config() {
8367 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8368 managers.insert(
8369 "tm_peers".to_string(),
8370 Box::new(MockTopicManager::admitting(vec![])),
8371 );
8372 managers.insert(
8373 "tm_disabled".to_string(),
8374 Box::new(MockTopicManager::admitting(vec![])),
8375 );
8376 managers.insert(
8377 "tm_ship_topic".to_string(),
8378 Box::new(MockTopicManager::admitting(vec![])),
8379 );
8380
8381 let mut sync_config: SyncConfiguration = HashMap::new();
8382 sync_config.insert(
8383 "tm_peers".to_string(),
8384 SyncTarget::Peers(vec!["https://peer.com".to_string()]),
8385 );
8386 sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
8387 sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
8388
8389 let engine = Engine::new(
8390 managers,
8391 HashMap::new(),
8392 Box::new(MemoryStorage::new()),
8393 None,
8394 EngineConfig {
8395 sync_configuration: sync_config,
8396 ..Default::default()
8397 },
8398 );
8399
8400 let result = engine.start_gasp_sync().await.unwrap();
8401 assert_eq!(result.topics_synced.len(), 2);
8402 assert!(result.topics_synced.contains_key("tm_peers"));
8403 assert!(result.topics_synced.contains_key("tm_ship_topic"));
8404 assert!(!result.topics_synced.contains_key("tm_disabled"));
8405
8406 assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
8407 assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
8408 assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
8409 }
8410
8411 #[tokio::test]
8412 async fn test_gasp_sync_result_serialization() {
8413 let mut topics_synced = HashMap::new();
8414 topics_synced.insert(
8415 "tm_test".to_string(),
8416 super::TopicSyncResult {
8417 expired_refused: 0,
8418 peers: vec!["https://peer.com".to_string()],
8419 sync_type: "ship".to_string(),
8420 errors: vec![],
8421 pruned_inputs: 0,
8422 discarded_graphs: 0,
8423 anchor_walk_held_graphs: 0,
8424 finalized_graphs: 0,
8425 deadline_dropped_graphs: 0,
8426 cursor_moves: Vec::new(),
8427 deferred_graphs: 0,
8428 resumed_graphs: 0,
8429 converged_graphs: 0,
8430 dropped_graphs: Vec::new(),
8431 stalled_graphs: 0,
8432 held_back_graphs: 0,
8433 },
8434 );
8435
8436 let result = super::GASPSyncResult { topics_synced };
8437 let json = serde_json::to_string(&result).unwrap();
8438 let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
8439
8440 assert_eq!(back.topics_synced.len(), 1);
8441 assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
8442 assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
8443 }
8444
8445 struct MockSyncRemote {
8449 utxos: Vec<crate::types::GASPOutput>,
8450 }
8451
8452 #[async_trait(?Send)]
8453 impl crate::gasp::GASPRemote for MockSyncRemote {
8454 async fn get_initial_response(
8455 &self,
8456 request: &crate::types::GASPInitialRequest,
8457 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
8458 let utxos: Vec<crate::types::GASPOutput> = self
8459 .utxos
8460 .iter()
8461 .filter(|u| u.score as u64 >= request.since)
8462 .cloned()
8463 .collect();
8464 Ok(crate::types::GASPInitialResponse {
8465 utxo_list: utxos,
8466 since: request.since,
8467 })
8468 }
8469 async fn get_initial_reply(
8470 &self,
8471 _: &crate::types::GASPInitialResponse,
8472 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
8473 Ok(crate::types::GASPInitialReply {
8474 utxo_list: Vec::new(),
8475 })
8476 }
8477 async fn request_node(
8478 &self,
8479 graph_id: &str,
8480 txid: &str,
8481 output_index: u32,
8482 _: bool,
8483 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
8484 Ok(crate::types::GASPNode {
8485 graph_id: graph_id.to_string(),
8486 raw_tx: format!("rawtx_{txid}"),
8487 output_index,
8488 proof: None,
8489 tx_metadata: None,
8490 output_metadata: None,
8491 inputs: None,
8492 })
8493 }
8494 async fn submit_node(
8495 &self,
8496 _: &crate::types::GASPNode,
8497 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
8498 Ok(None)
8499 }
8500 }
8501
8502 struct MockGASPRemoteFactory;
8504
8505 #[derive(Clone)]
8506 struct BudgetPageRemote {
8507 outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
8508 nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
8509 elapsed: std::rc::Rc<Cell<u64>>,
8510 requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
8511 clamp: Option<usize>,
8512 node_cost_ms: u64,
8513 }
8514
8515 #[async_trait(?Send)]
8516 impl crate::gasp::GASPRemote for BudgetPageRemote {
8517 async fn get_initial_response(
8518 &self,
8519 request: &crate::types::GASPInitialRequest,
8520 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
8521 self.requests.borrow_mut().push(request.clone());
8522 let page = self
8523 .outputs
8524 .iter()
8525 .filter(|u| u.score as u64 >= request.since);
8526 let limit = self.clamp.map_or(usize::MAX, |cap| {
8527 cap.min(request.limit.unwrap_or(u64::MAX) as usize)
8528 });
8529 Ok(crate::types::GASPInitialResponse {
8530 utxo_list: page.take(limit).cloned().collect(),
8531 since: request.since,
8532 })
8533 }
8534
8535 async fn get_initial_reply(
8536 &self,
8537 _: &crate::types::GASPInitialResponse,
8538 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
8539 unreachable!()
8540 }
8541
8542 async fn request_node(
8543 &self,
8544 graph_id: &str,
8545 txid: &str,
8546 _: u32,
8547 _: bool,
8548 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
8549 self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
8550 if self.node_cost_ms > 0 {
8551 let mut yielded = false;
8552 std::future::poll_fn(|cx| {
8553 if yielded {
8554 std::task::Poll::Ready(())
8555 } else {
8556 yielded = true;
8557 cx.waker().wake_by_ref();
8558 std::task::Poll::Pending
8559 }
8560 })
8561 .await;
8562 }
8563 let mut node = self
8564 .nodes
8565 .get(txid)
8566 .cloned()
8567 .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
8568 node.graph_id = graph_id.to_string();
8569 Ok(node)
8570 }
8571
8572 async fn submit_node(
8573 &self,
8574 _: &crate::types::GASPNode,
8575 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
8576 unreachable!()
8577 }
8578 }
8579
8580 impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
8581 fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
8582 Box::new(self.clone())
8583 }
8584 }
8585
8586 fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
8587 let mut engine = make_engine(vec![0]);
8588 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
8589 let mut outputs = Vec::new();
8590 let mut nodes = HashMap::new();
8591 for n in 1..=count {
8592 let tx = child_of(&format!("{n:064x}"), 0);
8593 let txid = tx.id();
8594 tracker.add_root(n, txid.clone());
8595 outputs.push(crate::types::GASPOutput {
8596 txid: txid.clone(),
8597 output_index: 0,
8598 score: f64::from(n * 10),
8599 });
8600 nodes.insert(
8601 txid.clone(),
8602 crate::types::GASPNode {
8603 graph_id: String::new(),
8604 raw_tx: tx.to_hex(),
8605 output_index: 0,
8606 proof: Some(single_leaf_bump_hex(&txid, n)),
8607 tx_metadata: None,
8608 output_metadata: None,
8609 inputs: None,
8610 },
8611 );
8612 }
8613 engine.chain_tracker = Some(Box::new(tracker));
8614 engine.config.sync_configuration.insert(
8615 "tm_test".to_string(),
8616 SyncTarget::Peers(vec!["https://budget.example".to_string()]),
8617 );
8618 let remote = BudgetPageRemote {
8619 outputs: std::rc::Rc::new(outputs),
8620 nodes: std::rc::Rc::new(nodes),
8621 elapsed: std::rc::Rc::new(Cell::new(0)),
8622 requests: std::rc::Rc::default(),
8623 clamp: None,
8624 node_cost_ms: 5000,
8625 };
8626 (engine, remote)
8627 }
8628
8629 fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
8630 let elapsed = remote.elapsed.clone();
8631 engine.set_peer_sync_budget(
8632 std::rc::Rc::new(move |ms| {
8633 assert_eq!(ms, 30_000);
8634 let elapsed = elapsed.clone();
8635 let until = elapsed.get() + ms;
8636 Box::pin(std::future::poll_fn(move |_| {
8637 if elapsed.get() >= until {
8638 std::task::Poll::Ready(())
8639 } else {
8640 std::task::Poll::Pending
8641 }
8642 }))
8643 }),
8644 30_000,
8645 );
8646 }
8647
8648 #[tokio::test]
8649 async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
8650 let (mut engine, remote) = budget_page_engine(3000);
8653 virtual_peer_budget(&mut engine, &remote);
8654 engine.set_gasp_remote_factory(Box::new(remote.clone()));
8655 for round in 1..=2 {
8656 let result = engine.start_gasp_sync().await.unwrap();
8657 let topic = &result.topics_synced["tm_test"];
8658 assert_eq!(topic.finalized_graphs, 5);
8659 assert_eq!(topic.deadline_dropped_graphs, 1);
8660 assert_eq!(topic.cursor_moves.len(), 1);
8661 let expected = round * 50;
8662 assert_eq!(topic.cursor_moves[0].to, expected);
8663 assert_eq!(
8664 engine
8665 .storage
8666 .get_last_interaction("https://budget.example", "tm_test")
8667 .await
8668 .unwrap(),
8669 expected
8670 );
8671 {
8672 let requests = remote.requests.borrow();
8673 assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
8674 }
8675 for output in remote.outputs.iter().take((round * 5) as usize) {
8676 assert!(engine
8677 .storage
8678 .find_output(&output.txid, 0, Some("tm_test"), None, true)
8679 .await
8680 .unwrap()
8681 .is_some());
8682 }
8683 let unfinished = &remote.outputs[(round * 5) as usize];
8684 assert!(engine
8685 .storage
8686 .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
8687 .await
8688 .unwrap()
8689 .is_none());
8690 }
8691 }
8692
8693 #[tokio::test]
8694 async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
8695 let (mut engine, mut remote) = budget_page_engine(7);
8696 remote.clamp = Some(3);
8697 remote.node_cost_ms = 0;
8698 virtual_peer_budget(&mut engine, &remote);
8699 engine.set_gasp_remote_factory(Box::new(remote.clone()));
8700 let result = engine.start_gasp_sync().await.unwrap();
8701 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
8702 let requests = remote.requests.borrow();
8703 assert_eq!(requests.len(), 4);
8704 assert!(requests.iter().all(|request| request.limit == Some(100)));
8705 assert_eq!(
8706 requests.iter().map(|r| r.since).collect::<Vec<_>>(),
8707 vec![0, 30, 50, 70]
8708 );
8709 }
8710
8711 #[tokio::test]
8712 async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
8713 let (mut engine, mut remote) = budget_page_engine(3000);
8714 std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
8715 virtual_peer_budget(&mut engine, &remote);
8716 engine.set_gasp_remote_factory(Box::new(remote));
8717 let result = engine.start_gasp_sync().await.unwrap();
8718 let topic = &result.topics_synced["tm_test"];
8719 assert_eq!(topic.finalized_graphs, 5);
8720 assert!(topic.cursor_moves.is_empty());
8723 }
8724
8725 #[tokio::test]
8726 async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
8727 let (mut engine, mut remote) = budget_page_engine(3);
8728 std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
8729 remote.node_cost_ms = 10_000;
8730 virtual_peer_budget(&mut engine, &remote);
8731 engine.set_gasp_remote_factory(Box::new(remote));
8732 let result = engine.start_gasp_sync().await.unwrap();
8733 let topic = &result.topics_synced["tm_test"];
8734 assert_eq!(topic.finalized_graphs, 2);
8735 assert_eq!(topic.cursor_moves[0].to, 10);
8736 }
8737
8738 #[tokio::test]
8739 async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
8740 let (mut engine, mut remote) = budget_page_engine(3000);
8741 let failed = remote.outputs[2].txid.clone();
8742 std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
8743 virtual_peer_budget(&mut engine, &remote);
8744 engine.set_gasp_remote_factory(Box::new(remote));
8745 let result = engine.start_gasp_sync().await.unwrap();
8746 let topic = &result.topics_synced["tm_test"];
8747 assert_eq!(topic.finalized_graphs, 4);
8748 assert_eq!(topic.cursor_moves[0].to, 20);
8749 }
8750
8751 struct ProofOnlyRootFetcher {
8752 proofs: HashMap<String, String>,
8753 requested: std::rc::Rc<Cell<u64>>,
8754 }
8755
8756 #[async_trait(?Send)]
8757 impl AncestorFetcher for ProofOnlyRootFetcher {
8758 async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
8759 Err(GASPError::RemoteError("raw source unavailable".to_string()))
8760 }
8761 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
8762 self.requested.set(self.requested.get() + 1);
8763 self.proofs.get(txid).cloned()
8764 }
8765 }
8766
8767 #[tokio::test]
8768 async fn copy_sync_leaves_completion_courier_untouched() {
8769 let (mut engine, mut remote) = budget_page_engine(1);
8770 remote.node_cost_ms = 0;
8771 let txid = remote.outputs[0].txid.clone();
8772 let proof = std::rc::Rc::make_mut(&mut remote.nodes)
8773 .get_mut(&txid)
8774 .unwrap()
8775 .proof
8776 .take()
8777 .unwrap();
8778 let completion_asks = std::rc::Rc::new(Cell::new(0));
8779 let sync_asks = std::rc::Rc::new(Cell::new(0));
8780 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8781 proofs: HashMap::new(),
8782 requested: completion_asks.clone(),
8783 }));
8784 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8785 proofs: HashMap::from([(txid.clone(), proof.clone())]),
8786 requested: sync_asks.clone(),
8787 }));
8788 engine.set_gasp_remote_factory(Box::new(remote));
8789 let result = engine.start_gasp_sync().await.unwrap();
8790 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
8791 assert_eq!(completion_asks.get(), 0);
8792 assert_eq!(sync_asks.get(), 1);
8793 let served = engine
8794 .provide_foreign_gasp_node(&format!("{txid}.0"), &txid, 0)
8795 .await
8796 .unwrap();
8797 assert_eq!(served.proof, Some(proof));
8798 }
8799
8800 #[tokio::test]
8801 async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
8802 for proven in [true, false] {
8803 let (mut engine, mut remote) = budget_page_engine(1);
8804 let txid = remote.outputs[0].txid.clone();
8805 let proof = remote.nodes[&txid].proof.clone().unwrap();
8806 std::rc::Rc::make_mut(&mut remote.nodes)
8807 .get_mut(&txid)
8808 .unwrap()
8809 .proof = None;
8810 let requested = std::rc::Rc::new(Cell::new(0));
8811 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8812 proofs: if proven {
8813 HashMap::from([(txid.clone(), proof)])
8814 } else {
8815 HashMap::new()
8816 },
8817 requested: requested.clone(),
8818 }));
8819 virtual_peer_budget(&mut engine, &remote);
8820 engine.set_gasp_remote_factory(Box::new(remote));
8821 let result = engine.start_gasp_sync().await.unwrap();
8822 assert!(requested.get() > 0);
8823 assert_eq!(
8824 result.topics_synced["tm_test"].finalized_graphs,
8825 u64::from(proven)
8826 );
8827 assert_eq!(
8828 engine
8829 .storage
8830 .find_output(&txid, 0, Some("tm_test"), None, true)
8831 .await
8832 .unwrap()
8833 .is_some(),
8834 proven
8835 );
8836 if !proven {
8837 assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
8838 }
8839 }
8840 }
8841
8842 #[tokio::test]
8843 async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
8844 let (mut engine, mut remote) = budget_page_engine(1);
8845 let txid = remote.outputs[0].txid.clone();
8846 let proof = remote.nodes[&txid].proof.clone().unwrap();
8847 std::rc::Rc::make_mut(&mut remote.nodes)
8848 .get_mut(&txid)
8849 .unwrap()
8850 .proof = None;
8851 let requested = std::rc::Rc::new(Cell::new(0));
8852 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8853 proofs: HashMap::new(),
8854 requested: requested.clone(),
8855 }));
8856 virtual_peer_budget(&mut engine, &remote);
8857 engine.set_graph_budget(
8858 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
8859 5,
8860 5000,
8861 );
8862 engine.set_gasp_remote_factory(Box::new(remote));
8863 let held = engine.start_gasp_sync().await.unwrap();
8864 assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
8865 assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
8866 assert!(held.topics_synced["tm_test"].cursor_moves.is_empty());
8867 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8868 proofs: HashMap::from([(txid.clone(), proof)]),
8869 requested,
8870 }));
8871 let landed = engine.start_gasp_sync().await.unwrap();
8872 assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
8873 assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
8874 assert_eq!(landed.topics_synced["tm_test"].cursor_moves[0].to, 10);
8875 assert!(engine
8876 .storage
8877 .find_output(&txid, 0, Some("tm_test"), None, true)
8878 .await
8879 .unwrap()
8880 .is_some());
8881 }
8882
8883 #[tokio::test]
8884 async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
8885 let (mut engine, mut remote) = budget_page_engine(1);
8886 let txid = remote.outputs[0].txid.clone();
8887 std::rc::Rc::make_mut(&mut remote.nodes)
8888 .get_mut(&txid)
8889 .unwrap()
8890 .proof = None;
8891 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8892 proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
8893 requested: std::rc::Rc::default(),
8894 }));
8895 virtual_peer_budget(&mut engine, &remote);
8896 engine.set_gasp_remote_factory(Box::new(remote));
8897 let result = engine.start_gasp_sync().await.unwrap();
8898 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
8899 assert!(engine
8900 .storage
8901 .find_output(&txid, 0, Some("tm_test"), None, true)
8902 .await
8903 .unwrap()
8904 .is_none());
8905 }
8906
8907 impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
8908 fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8909 Box::new(MockSyncRemote {
8910 utxos: vec![
8911 crate::types::GASPOutput {
8912 txid: "remote_tx1".to_string(),
8913 output_index: 0,
8914 score: 100.0,
8915 },
8916 crate::types::GASPOutput {
8917 txid: "remote_tx2".to_string(),
8918 output_index: 0,
8919 score: 200.0,
8920 },
8921 ],
8922 })
8923 }
8924 }
8925
8926 #[tokio::test]
8927 async fn test_start_gasp_sync_with_factory_runs_sync() {
8928 let mut sync_config: SyncConfiguration = HashMap::new();
8929 sync_config.insert(
8930 "tm_test".to_string(),
8931 SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
8932 );
8933
8934 let mut engine = Engine::new(
8935 HashMap::new(),
8936 HashMap::new(),
8937 Box::new(MemoryStorage::new()),
8938 None,
8939 EngineConfig {
8940 sync_configuration: sync_config,
8941 ..Default::default()
8942 },
8943 );
8944
8945 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
8946
8947 let result = engine.start_gasp_sync().await.unwrap();
8948 assert_eq!(result.topics_synced.len(), 1);
8949
8950 let topic_result = &result.topics_synced["tm_test"];
8951 assert_eq!(topic_result.peers.len(), 1);
8952 assert!(
8953 topic_result.errors.is_empty(),
8954 "Sync should succeed with mock remote"
8955 );
8956
8957 let last = engine
8959 .storage()
8960 .get_last_interaction("https://peer1.example.com", "tm_test")
8961 .await
8962 .unwrap();
8963 assert_eq!(
8966 last, 0,
8967 "cursor must remain at entry when no output completed"
8968 );
8969 }
8970
8971 #[tokio::test]
8972 async fn test_start_gasp_sync_with_factory_handles_error() {
8973 struct FailingFactory;
8974
8975 impl crate::gasp::GASPRemoteFactory for FailingFactory {
8976 fn create_remote(
8977 &self,
8978 _peer_url: &str,
8979 _topic: &str,
8980 ) -> Box<dyn crate::gasp::GASPRemote> {
8981 struct FailingRemote;
8982 #[async_trait(?Send)]
8983 impl crate::gasp::GASPRemote for FailingRemote {
8984 async fn get_initial_response(
8985 &self,
8986 _: &crate::types::GASPInitialRequest,
8987 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
8988 {
8989 Err(crate::gasp::GASPError::RemoteError(
8990 "connection refused".into(),
8991 ))
8992 }
8993 async fn get_initial_reply(
8994 &self,
8995 _: &crate::types::GASPInitialResponse,
8996 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
8997 {
8998 unreachable!()
8999 }
9000 async fn request_node(
9001 &self,
9002 _: &str,
9003 _: &str,
9004 _: u32,
9005 _: bool,
9006 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
9007 {
9008 unreachable!()
9009 }
9010 async fn submit_node(
9011 &self,
9012 _: &crate::types::GASPNode,
9013 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
9014 {
9015 unreachable!()
9016 }
9017 }
9018 Box::new(FailingRemote)
9019 }
9020 }
9021
9022 let mut sync_config: SyncConfiguration = HashMap::new();
9023 sync_config.insert(
9024 "tm_test".to_string(),
9025 SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
9026 );
9027
9028 let mut engine = Engine::new(
9029 HashMap::new(),
9030 HashMap::new(),
9031 Box::new(MemoryStorage::new()),
9032 None,
9033 EngineConfig {
9034 sync_configuration: sync_config,
9035 ..Default::default()
9036 },
9037 );
9038
9039 engine.set_gasp_remote_factory(Box::new(FailingFactory));
9040
9041 let result = engine.start_gasp_sync().await.unwrap();
9042 assert_eq!(result.topics_synced.len(), 1);
9043
9044 let topic_result = &result.topics_synced["tm_test"];
9045 assert_eq!(
9046 topic_result.errors.len(),
9047 1,
9048 "Should have one error for the failing peer"
9049 );
9050 assert!(topic_result.errors[0].contains("connection refused"));
9051 }
9052
9053 #[tokio::test]
9054 async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
9055 let mut sync_config: SyncConfiguration = HashMap::new();
9056 sync_config.insert(
9057 "tm_test".to_string(),
9058 SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
9059 );
9060
9061 let engine = Engine::new(
9063 HashMap::new(),
9064 HashMap::new(),
9065 Box::new(MemoryStorage::new()),
9066 None,
9067 EngineConfig {
9068 sync_configuration: sync_config,
9069 ..Default::default()
9070 },
9071 );
9072
9073 let result = engine.start_gasp_sync().await.unwrap();
9074 assert_eq!(result.topics_synced.len(), 1);
9075
9076 let topic_result = &result.topics_synced["tm_test"];
9077 assert_eq!(topic_result.peers.len(), 1);
9078 assert!(topic_result.errors.is_empty());
9079 }
9080
9081 #[tokio::test]
9082 async fn test_start_gasp_sync_with_factory_multiple_peers() {
9083 let mut sync_config: SyncConfiguration = HashMap::new();
9084 sync_config.insert(
9085 "tm_test".to_string(),
9086 SyncTarget::Peers(vec![
9087 "https://peer1.example.com".to_string(),
9088 "https://peer2.example.com".to_string(),
9089 ]),
9090 );
9091
9092 let mut engine = Engine::new(
9093 HashMap::new(),
9094 HashMap::new(),
9095 Box::new(MemoryStorage::new()),
9096 None,
9097 EngineConfig {
9098 sync_configuration: sync_config,
9099 ..Default::default()
9100 },
9101 );
9102
9103 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
9104
9105 let result = engine.start_gasp_sync().await.unwrap();
9106 let topic_result = &result.topics_synced["tm_test"];
9107 assert_eq!(topic_result.peers.len(), 2);
9108 assert!(topic_result.errors.is_empty());
9109
9110 let last1 = engine
9112 .storage()
9113 .get_last_interaction("https://peer1.example.com", "tm_test")
9114 .await
9115 .unwrap();
9116 let last2 = engine
9117 .storage()
9118 .get_last_interaction("https://peer2.example.com", "tm_test")
9119 .await
9120 .unwrap();
9121 assert_eq!(last1, 0);
9122 assert_eq!(last2, 0);
9123 }
9124
9125 struct HangingRemote;
9131
9132 #[async_trait(?Send)]
9133 impl crate::gasp::GASPRemote for HangingRemote {
9134 async fn get_initial_response(
9135 &self,
9136 _: &crate::types::GASPInitialRequest,
9137 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9138 std::future::pending::<()>().await;
9139 unreachable!()
9140 }
9141 async fn get_initial_reply(
9142 &self,
9143 _: &crate::types::GASPInitialResponse,
9144 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9145 unreachable!()
9146 }
9147 async fn request_node(
9148 &self,
9149 _: &str,
9150 _: &str,
9151 _: u32,
9152 _: bool,
9153 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9154 unreachable!()
9155 }
9156 async fn submit_node(
9157 &self,
9158 _: &crate::types::GASPNode,
9159 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9160 unreachable!()
9161 }
9162 }
9163
9164 struct SelectiveHangFactory {
9168 created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
9169 }
9170
9171 impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
9172 fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
9173 self.created.borrow_mut().push(peer_url.to_string());
9174 if peer_url.contains("hang.example.com") {
9175 Box::new(HangingRemote)
9176 } else {
9177 Box::new(MockSyncRemote {
9178 utxos: vec![crate::types::GASPOutput {
9179 txid: "remote_tx1".to_string(),
9180 output_index: 0,
9181 score: 100.0,
9182 }],
9183 })
9184 }
9185 }
9186 }
9187
9188 fn instant_deadline() -> crate::engine::SleepFactory {
9193 std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
9194 }
9195
9196 fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
9197 let mut sync_config: SyncConfiguration = HashMap::new();
9198 sync_config.insert(
9199 "tm_test".to_string(),
9200 SyncTarget::Peers(vec![
9201 "https://hang.example.com".to_string(),
9202 "https://good.example.com".to_string(),
9203 ]),
9204 );
9205 Engine::new(
9206 HashMap::new(),
9207 HashMap::new(),
9208 Box::new(store),
9209 None,
9210 EngineConfig {
9211 sync_configuration: sync_config,
9212 ..Default::default()
9213 },
9214 )
9215 }
9216
9217 #[tokio::test]
9218 async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
9219 let store = std::rc::Rc::new(MemoryStorage::new());
9220 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
9221 let mut engine = two_peer_engine(store.clone());
9222 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
9223 created: created.clone(),
9224 }));
9225 engine.set_peer_sync_budget(instant_deadline(), 1);
9226
9227 let result = engine.start_gasp_sync().await.unwrap();
9228 let topic_result = &result.topics_synced["tm_test"];
9229
9230 assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
9233 assert!(
9234 topic_result.errors[0].contains("budget"),
9235 "{:?}",
9236 topic_result.errors
9237 );
9238 assert_eq!(
9239 created.borrow().as_slice(),
9240 ["https://hang.example.com", "https://good.example.com"],
9241 "both peers attempted this tick"
9242 );
9243
9244 let hang_cursor = store
9247 .get_last_interaction("https://hang.example.com", "tm_test")
9248 .await
9249 .unwrap();
9250 assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
9251 let good_cursor = store
9252 .get_last_interaction("https://good.example.com", "tm_test")
9253 .await
9254 .unwrap();
9255 assert_eq!(good_cursor, 0, "no synthetic graph completed");
9256
9257 let hang_health = store
9259 .get_peer_sync_health("hang.example.com", "tm_test")
9260 .await
9261 .unwrap();
9262 assert_eq!(hang_health.consecutive_failures, 1);
9263 let good_health = store
9264 .get_peer_sync_health("good.example.com", "tm_test")
9265 .await
9266 .unwrap();
9267 assert_eq!(good_health.consecutive_failures, 0);
9268 }
9269
9270 #[tokio::test]
9271 async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
9272 let store = std::rc::Rc::new(MemoryStorage::new());
9273 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
9274
9275 let mut sync_config: SyncConfiguration = HashMap::new();
9276 sync_config.insert(
9277 "tm_test".to_string(),
9278 SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
9279 );
9280 let mut engine = Engine::new(
9281 HashMap::new(),
9282 HashMap::new(),
9283 Box::new(store.clone()),
9284 None,
9285 EngineConfig {
9286 sync_configuration: sync_config,
9287 ..Default::default()
9288 },
9289 );
9290 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
9291 created: created.clone(),
9292 }));
9293 engine.set_peer_sync_budget(instant_deadline(), 1);
9294
9295 for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
9299 engine.start_gasp_sync().await.unwrap();
9300 store.advance_clock(900);
9301 assert_eq!(
9302 created.borrow().len() as u64,
9303 i + 1,
9304 "peer attempted while below the threshold"
9305 );
9306 }
9307 let health = store
9308 .get_peer_sync_health("hang.example.com", "tm_test")
9309 .await
9310 .unwrap();
9311 assert_eq!(
9312 health.consecutive_failures,
9313 crate::gasp::PEER_QUARANTINE_THRESHOLD
9314 );
9315
9316 let result = engine.start_gasp_sync().await.unwrap();
9320 assert!(result.topics_synced["tm_test"].errors.is_empty());
9321 assert_eq!(
9322 created.borrow().len() as u64,
9323 crate::gasp::PEER_QUARANTINE_THRESHOLD,
9324 "quarantined peer must be skipped"
9325 );
9326
9327 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
9329 engine.start_gasp_sync().await.unwrap();
9330 assert_eq!(
9331 created.borrow().len() as u64,
9332 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
9333 "re-probe attempted after the window"
9334 );
9335 let health = store
9337 .get_peer_sync_health("hang.example.com", "tm_test")
9338 .await
9339 .unwrap();
9340 assert_eq!(
9341 health.consecutive_failures,
9342 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
9343 );
9344 let result = engine.start_gasp_sync().await.unwrap();
9345 assert!(
9346 result.topics_synced["tm_test"].errors.is_empty(),
9347 "re-quarantined"
9348 );
9349
9350 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
9354 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
9356 let result = engine.start_gasp_sync().await.unwrap();
9357 assert!(result.topics_synced["tm_test"].errors.is_empty());
9358 let health = store
9359 .get_peer_sync_health("hang.example.com", "tm_test")
9360 .await
9361 .unwrap();
9362 assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
9363 }
9364
9365 use crate::broadcaster::ArcBroadcaster;
9368
9369 struct MockArcBroadcaster {
9370 calls: Arc<Mutex<Vec<String>>>,
9371 should_fail: bool,
9372 }
9373
9374 impl MockArcBroadcaster {
9375 fn new() -> Self {
9376 Self {
9377 calls: Arc::new(Mutex::new(Vec::new())),
9378 should_fail: false,
9379 }
9380 }
9381
9382 fn failing() -> Self {
9383 Self {
9384 calls: Arc::new(Mutex::new(Vec::new())),
9385 should_fail: true,
9386 }
9387 }
9388 }
9389
9390 #[async_trait(?Send)]
9391 impl ArcBroadcaster for MockArcBroadcaster {
9392 async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
9393 self.calls.lock().unwrap().push(raw_tx_hex.to_string());
9394 if self.should_fail {
9395 Err("mock ARC failure".to_string())
9396 } else {
9397 Ok("mock_txid_from_arc".to_string())
9398 }
9399 }
9400 }
9401
9402 fn make_engine_with_arc(
9404 admit_indices: Vec<u32>,
9405 arc: MockArcBroadcaster,
9406 ) -> (Engine, Arc<Mutex<Vec<String>>>) {
9407 let arc_calls = arc.calls.clone();
9408
9409 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9410 managers.insert(
9411 "tm_test".to_string(),
9412 Box::new(MockTopicManager::admitting(admit_indices)),
9413 );
9414
9415 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
9416 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
9417
9418 let storage = Box::new(MemoryStorage::new());
9419
9420 let engine = Engine::with_all(
9421 managers,
9422 lookup_services,
9423 storage,
9424 None,
9425 None,
9426 Some(Box::new(arc)),
9427 None,
9428 EngineConfig::default(),
9429 );
9430
9431 (engine, arc_calls)
9432 }
9433
9434 #[tokio::test]
9437 async fn test_arc_broadcaster_called_on_current_tx() {
9438 let arc = MockArcBroadcaster::new();
9439 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9440
9441 let beef = test_tagged_beef(vec!["tm_test"]);
9442 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
9443
9444 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
9445
9446 let calls = arc_calls.lock().unwrap();
9447 assert_eq!(
9448 calls.len(),
9449 1,
9450 "ARC broadcaster should be called once for CurrentTx"
9451 );
9452 assert!(
9453 !calls[0].is_empty(),
9454 "ARC should receive non-empty raw tx hex"
9455 );
9456 }
9457
9458 #[tokio::test]
9459 async fn test_arc_broadcaster_not_called_on_historical_tx() {
9460 let arc = MockArcBroadcaster::new();
9461 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9462
9463 let beef = test_tagged_beef(vec!["tm_test"]);
9464 engine
9465 .submit(&beef, SubmitMode::HistoricalTx)
9466 .await
9467 .unwrap();
9468
9469 assert_eq!(
9470 arc_calls.lock().unwrap().len(),
9471 0,
9472 "ARC broadcaster should NOT be called for HistoricalTx"
9473 );
9474 }
9475
9476 #[tokio::test]
9477 async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
9478 let arc = MockArcBroadcaster::new();
9479 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9480
9481 let beef = test_tagged_beef(vec!["tm_test"]);
9482 engine
9483 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
9484 .await
9485 .unwrap();
9486
9487 assert_eq!(
9488 arc_calls.lock().unwrap().len(),
9489 0,
9490 "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
9491 );
9492 }
9493
9494 #[tokio::test]
9495 async fn test_arc_broadcast_failure_does_not_fail_submit() {
9496 let arc = MockArcBroadcaster::failing();
9497 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
9498
9499 let beef = test_tagged_beef(vec!["tm_test"]);
9500 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
9502
9503 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
9504
9505 assert_eq!(
9507 arc_calls.lock().unwrap().len(),
9508 1,
9509 "ARC broadcaster should be attempted even if it will fail"
9510 );
9511 }
9512
9513 #[tokio::test]
9514 async fn test_arc_broadcaster_not_present_still_works() {
9515 let engine = make_engine(vec![0]);
9517
9518 let beef = test_tagged_beef(vec!["tm_test"]);
9519 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
9520
9521 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
9522 }
9523
9524 struct TrackingAdvertiser {
9528 created: Arc<Mutex<Vec<AdvertisementData>>>,
9529 existing_ship: Vec<Advertisement>,
9530 existing_slap: Vec<Advertisement>,
9531 tag_topics: Vec<String>,
9535 create_fails: bool,
9537 find_fails: bool,
9539 revoke_empty: bool,
9541 }
9542
9543 impl TrackingAdvertiser {
9544 fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
9545 let created = Arc::new(Mutex::new(Vec::new()));
9546 (
9547 Self {
9548 created: created.clone(),
9549 existing_ship: vec![],
9550 existing_slap: vec![],
9551 tag_topics: vec!["tm_test".to_string()],
9552 create_fails: false,
9553 find_fails: false,
9554 revoke_empty: false,
9555 },
9556 created,
9557 )
9558 }
9559 }
9560
9561 use crate::advertiser::{Advertiser, AdvertiserError};
9562
9563 #[async_trait(?Send)]
9564 impl Advertiser for TrackingAdvertiser {
9565 async fn create_advertisements(
9566 &self,
9567 ads: &[AdvertisementData],
9568 ) -> Result<TaggedBEEF, AdvertiserError> {
9569 if self.create_fails {
9570 return Err(AdvertiserError::CreationFailed(
9571 "mock create refused".into(),
9572 ));
9573 }
9574 self.created.lock().unwrap().extend(ads.iter().cloned());
9575 Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
9576 }
9577
9578 async fn find_all_advertisements(
9579 &self,
9580 protocol: Protocol,
9581 ) -> Result<Vec<Advertisement>, AdvertiserError> {
9582 if self.find_fails {
9583 return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
9584 }
9585 match protocol {
9586 Protocol::Ship => Ok(self.existing_ship.clone()),
9587 Protocol::Slap => Ok(self.existing_slap.clone()),
9588 }
9589 }
9590
9591 async fn revoke_advertisements(
9592 &self,
9593 _ads: &[Advertisement],
9594 ) -> Result<TaggedBEEF, AdvertiserError> {
9595 if self.revoke_empty {
9596 return Ok(TaggedBEEF::new(vec![], vec![]));
9597 }
9598 Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
9599 }
9600
9601 fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
9602 None
9603 }
9604 }
9605
9606 #[tokio::test]
9610 async fn test_sync_advertisements_url_validation() {
9611 let (adv, created) = TrackingAdvertiser::new();
9613
9614 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9615 managers.insert(
9616 "tm_test".to_string(),
9617 Box::new(MockTopicManager::admitting(vec![0])),
9618 );
9619
9620 let engine = Engine::new(
9621 managers,
9622 HashMap::new(),
9623 Box::new(MemoryStorage::new()),
9624 Some(Box::new(adv)),
9625 EngineConfig {
9626 hosting_url: Some(String::new()), ..Default::default()
9628 },
9629 );
9630
9631 engine.sync_advertisements().await.unwrap();
9632 assert!(
9633 created.lock().unwrap().is_empty(),
9634 "Empty hosting URL should skip advertisement creation"
9635 );
9636
9637 let (adv2, created2) = TrackingAdvertiser::new();
9639
9640 let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9641 managers2.insert(
9642 "tm_test".to_string(),
9643 Box::new(MockTopicManager::admitting(vec![0])),
9644 );
9645 managers2.insert(
9647 "tm_ship".to_string(),
9648 Box::new(MockTopicManager::admitting(vec![])),
9649 );
9650
9651 let engine2 = Engine::new(
9652 managers2,
9653 HashMap::new(),
9654 Box::new(MemoryStorage::new()),
9655 Some(Box::new(adv2)),
9656 EngineConfig {
9657 hosting_url: Some("https://valid.example.com".to_string()),
9658 suppress_default_sync_advertisements: true,
9659 ..Default::default()
9660 },
9661 );
9662
9663 engine2.sync_advertisements().await.unwrap();
9664 let ads = created2.lock().unwrap();
9665 assert!(
9667 ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
9668 "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
9669 );
9670 assert!(
9671 ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
9672 "tm_test should get a SHIP advertisement"
9673 );
9674 }
9675
9676 fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
9685 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9686 managers.insert(
9687 "tm_test".to_string(),
9688 Box::new(MockTopicManager::admitting(vec![0])),
9689 );
9690 Engine::new(
9691 managers,
9692 HashMap::new(),
9693 Box::new(MemoryStorage::new()),
9694 Some(Box::new(adv)),
9695 EngineConfig {
9696 hosting_url: Some("https://valid.example.com".to_string()),
9697 suppress_default_sync_advertisements: true,
9698 ..Default::default()
9699 },
9700 )
9701 }
9702
9703 #[tokio::test]
9704 async fn sync_report_counts_admitted_outputs_on_success() {
9705 let (adv, _) = TrackingAdvertiser::new();
9706 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9707 assert!(report.ok(), "clean run must report ok: {report:?}");
9708 assert_eq!(report.to_create, 1, "tm_test was missing");
9709 assert_eq!(
9710 report.admitted.get("tm_test"),
9711 Some(&1),
9712 "the STEAK's admitted count must be surfaced: {report:?}"
9713 );
9714 }
9715
9716 #[tokio::test]
9717 async fn sync_report_surfaces_local_submit_failure() {
9718 let (mut adv, _) = TrackingAdvertiser::new();
9722 adv.tag_topics = vec!["tm_unregistered".to_string()];
9723 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9724 assert!(!report.ok(), "a failed local submit must not report ok");
9725 assert_eq!(report.to_create, 1);
9726 let submit_error = report.submit_error.as_deref().unwrap_or_default();
9727 assert!(
9728 submit_error.contains("tm_unregistered"),
9729 "verbatim engine error expected, got: {submit_error}"
9730 );
9731 assert!(report.admitted.is_empty());
9732 }
9733
9734 #[tokio::test]
9735 async fn sync_report_surfaces_create_failure() {
9736 let (mut adv, _) = TrackingAdvertiser::new();
9737 adv.create_fails = true;
9738 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9739 assert!(!report.ok());
9740 assert!(
9741 report
9742 .create_error
9743 .as_deref()
9744 .unwrap_or_default()
9745 .contains("mock create refused"),
9746 "{report:?}"
9747 );
9748 assert!(report.submit_error.is_none(), "create failed before submit");
9749 }
9750
9751 #[tokio::test]
9752 async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
9753 let (mut adv, created) = TrackingAdvertiser::new();
9757 adv.existing_ship = vec![Advertisement {
9758 protocol: Protocol::Ship,
9759 identity_key: "02aa".to_string(),
9760 domain: "https://valid.example.com".to_string(),
9761 topic_or_service: "tm_test".to_string(),
9762 beef: None,
9763 output_index: None,
9764 }];
9765 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9766 assert!(report.ok(), "{report:?}");
9767 assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
9768 assert!(created.lock().unwrap().is_empty());
9769 assert!(
9770 report.effective(),
9771 "converged-noop is the one zero-admit shape that IS success"
9772 );
9773 }
9774
9775 #[tokio::test]
9776 async fn sync_report_zero_admit_is_ok_but_not_effective() {
9777 let (adv, _) = TrackingAdvertiser::new();
9783 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9784 managers.insert(
9785 "tm_test".to_string(),
9786 Box::new(MockTopicManager::admitting(vec![])), );
9788 let engine = Engine::new(
9789 managers,
9790 HashMap::new(),
9791 Box::new(MemoryStorage::new()),
9792 Some(Box::new(adv)),
9793 EngineConfig {
9794 hosting_url: Some("https://valid.example.com".to_string()),
9795 suppress_default_sync_advertisements: true,
9796 ..Default::default()
9797 },
9798 );
9799 let report = engine.sync_advertisements().await.unwrap();
9800 assert!(report.ok(), "no ERROR occurred: {report:?}");
9801 assert_eq!(report.to_create, 1);
9802 assert_eq!(report.admitted_total(), 0, "{report:?}");
9803 assert!(
9804 !report.effective(),
9805 "zero admitted on a non-empty create must not read as success: {report:?}"
9806 );
9807 }
9808
9809 #[tokio::test]
9810 async fn sync_report_partial_admit_is_not_effective() {
9811 let (mut adv, _) = TrackingAdvertiser::new();
9817 adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
9818 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9819 managers.insert(
9820 "tm_test".to_string(),
9821 Box::new(MockTopicManager::admitting(vec![0])),
9822 );
9823 managers.insert(
9824 "tm_refuse".to_string(),
9825 Box::new(MockTopicManager::admitting(vec![])), );
9827 let engine = Engine::new(
9828 managers,
9829 HashMap::new(),
9830 Box::new(MemoryStorage::new()),
9831 Some(Box::new(adv)),
9832 EngineConfig {
9833 hosting_url: Some("https://valid.example.com".to_string()),
9834 suppress_default_sync_advertisements: true,
9835 ..Default::default()
9836 },
9837 );
9838 let report = engine.sync_advertisements().await.unwrap();
9839 assert!(report.ok(), "no ERROR occurred: {report:?}");
9840 assert_eq!(report.to_create, 2, "{report:?}");
9841 assert_eq!(report.admitted_total(), 1, "{report:?}");
9842 assert!(
9843 !report.effective(),
9844 "a partial admit must not read as success: {report:?}"
9845 );
9846 }
9847
9848 #[tokio::test]
9849 async fn sync_report_lookup_failure_refuses_creation() {
9850 let (mut adv, created) = TrackingAdvertiser::new();
9854 adv.find_fails = true;
9855 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9856 assert!(!report.ok(), "{report:?}");
9857 assert!(!report.effective());
9858 assert!(
9859 report
9860 .lookup_error
9861 .as_deref()
9862 .unwrap_or_default()
9863 .contains("mock lookup refused"),
9864 "{report:?}"
9865 );
9866 assert_eq!(report.to_create, 0, "creation refused, not attempted");
9867 assert!(
9868 created.lock().unwrap().is_empty(),
9869 "no create call on a blind read"
9870 );
9871 assert!(report.submit_error.is_none());
9872 }
9873
9874 #[tokio::test]
9875 async fn sync_report_counts_skipped_revocations() {
9876 let (mut adv, _) = TrackingAdvertiser::new();
9879 adv.revoke_empty = true;
9880 adv.existing_ship = vec![
9881 Advertisement {
9882 protocol: Protocol::Ship,
9883 identity_key: "02aa".to_string(),
9884 domain: "https://valid.example.com".to_string(),
9885 topic_or_service: "tm_test".to_string(),
9886 beef: None,
9887 output_index: None,
9888 },
9889 Advertisement {
9890 protocol: Protocol::Ship,
9891 identity_key: "02aa".to_string(),
9892 domain: "https://valid.example.com".to_string(),
9893 topic_or_service: "tm_stale".to_string(), beef: None,
9895 output_index: None,
9896 },
9897 ];
9898 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
9899 assert!(report.ok(), "{report:?}");
9900 assert_eq!(report.to_revoke, 1, "tm_stale is stale");
9901 assert_eq!(
9902 report.revoke_skipped, 1,
9903 "the v1 no-op must be visible, not silent: {report:?}"
9904 );
9905 assert!(report.revoke_error.is_none());
9906 assert!(report.revoke_submit_error.is_none());
9907 }
9908
9909 use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
9912 use std::cell::Cell;
9913
9914 const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
9916
9917 fn proofless_beef() -> (Vec<u8>, String) {
9919 use bsv_rs::transaction::{Beef, Transaction};
9920 let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
9921 let txid = tx.id();
9922 let mut beef = Beef::new();
9923 beef.merge_transaction(tx);
9924 (beef.to_binary(), txid)
9925 }
9926
9927 fn subject_not_last_beef() -> (Vec<u8>, String, String) {
9937 use bsv_rs::transaction::Beef;
9938 let parsed = Beef::from_binary(&test_beef()).unwrap();
9939 let parent = parsed.txs[0].tx().unwrap().clone();
9940 let child = parsed.txs[1].tx().unwrap().clone();
9941 let (parent_id, child_id) = (parent.id(), child.id());
9942 assert_ne!(parent_id, child_id);
9943
9944 let mut beef = Beef::new();
9950 beef.merge_transaction(child); beef.merge_transaction(parent);
9952 let mut w = bsv_rs::primitives::encoding::Writer::new();
9953 beef.to_writer(&mut w);
9954 let bytes = w.into_bytes();
9955
9956 let reread = Beef::from_binary(&bytes).unwrap();
9959 assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
9960 (bytes, child_id, parent_id)
9961 }
9962
9963 #[test]
9964 fn stitch_preserves_the_subject_when_it_is_not_last() {
9965 use bsv_rs::transaction::{Beef, MerklePath};
9966 let (stored, child_id, parent_id) = subject_not_last_beef();
9967 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
9968
9969 let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
9970 .expect("stitch must succeed on a beef that contains the txid");
9971
9972 let after = Beef::from_binary(&out).unwrap();
9973 assert!(
9974 after.find_txid(&child_id).is_some(),
9975 "the SUBJECT must survive its own proof-completion"
9976 );
9977 assert!(
9978 after.find_txid(&parent_id).is_some(),
9979 "the ancestry must survive too"
9980 );
9981 assert!(
9982 after.find_bump(&child_id).is_some(),
9983 "the stitched bump must prove the subject"
9984 );
9985 assert!(
9986 after.find_bump(&parent_id).is_none(),
9987 "the parent must NOT be claimed by the child's bump"
9988 );
9989 }
9990
9991 #[test]
9996 fn the_old_round_trip_demonstrably_dropped_the_subject() {
9997 use bsv_rs::transaction::{Beef, Transaction};
9998 let (stored, child_id, _parent_id) = subject_not_last_beef();
9999 let tx = Transaction::from_beef(&stored, None).unwrap();
10000 assert_ne!(
10001 tx.id(),
10002 child_id,
10003 "from_beef(None) picks txs.last(), not the subject — the trigger"
10004 );
10005 let rebuilt = tx.to_beef(true).unwrap();
10006 let reparsed = Beef::from_binary(&rebuilt).unwrap();
10007 assert!(
10008 reparsed.find_txid(&child_id).is_none(),
10009 "the rebuild loses the subject — exactly the #284 artifact"
10010 );
10011 }
10012
10013 #[test]
10019 fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
10020 use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
10021 let parent = Transaction::from_hex(RAW_PARENT).unwrap();
10023 let parent_id = parent.id();
10024 let subject = child_of(&parent_id, 0);
10025 let subject_id = subject.id();
10026 let orphan = MerklePath::new_unchecked(
10029 965_771,
10030 vec![vec![
10031 MerklePathLeaf::new_txid(0, subject_id.clone()),
10032 MerklePathLeaf::new(1, "aa".repeat(32)),
10033 ]],
10034 )
10035 .unwrap();
10036 let mut beef = Beef::new();
10037 let bi = beef.merge_bump(orphan.clone());
10038 beef.merge_raw_tx(parent.to_binary(), None);
10039 beef.merge_raw_tx(subject.to_binary(), Some(bi));
10040 let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
10041 let stored = beef.to_binary();
10042 assert_eq!(
10043 Beef::from_binary(&stored)
10044 .unwrap()
10045 .find_bump(&subject_id)
10046 .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
10047 Some(orphan_root.clone())
10048 );
10049 let canonical = MerklePath::new_unchecked(
10051 965_771,
10052 vec![vec![
10053 MerklePathLeaf::new_txid(0, subject_id.clone()),
10054 MerklePathLeaf::new(1, "bb".repeat(32)),
10055 ]],
10056 )
10057 .unwrap();
10058 let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
10059 assert_ne!(
10060 canonical_root, orphan_root,
10061 "the reorg shape: same height, new root"
10062 );
10063 let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
10064 let after = Beef::from_binary(&out).unwrap();
10065 let own = after
10067 .find_txid(&subject_id)
10068 .and_then(BeefTx::bump_index)
10069 .and_then(|bi| after.bumps.get(bi))
10070 .unwrap();
10071 assert_eq!(
10072 own.compute_root(Some(&subject_id)).unwrap(),
10073 canonical_root,
10074 "the subject re-anchored to the new bump"
10075 );
10076 assert_eq!(
10078 after.bumps.len(),
10079 1,
10080 "the unreferenced orphan bump was dropped"
10081 );
10082 assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
10084 }
10085
10086 #[test]
10087 fn stitch_refuses_a_beef_that_lacks_the_txid() {
10088 use bsv_rs::transaction::MerklePath;
10089 let (stored, _c, _p) = subject_not_last_beef();
10090 let foreign = "aa".repeat(32);
10091 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
10092 assert!(
10093 Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
10094 "no txid in the beef -> no write, never garbage"
10095 );
10096 }
10097
10098 const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
10100
10101 fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
10103 let mut sx = String::from("0100000001");
10104 let mut prev = hex::decode(source_txid).unwrap();
10105 prev.reverse();
10106 sx.push_str(&hex::encode(prev));
10107 sx.push_str(&hex::encode(vout.to_le_bytes()));
10108 sx.push_str("00ffffffff01");
10109 sx.push_str(&hex::encode(1000u64.to_le_bytes()));
10110 sx.push_str("015100000000");
10111 bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
10112 }
10113
10114 fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
10116 use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
10117 let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
10118 MerklePath::new_unchecked(height, vec![vec![leaf]])
10119 .unwrap()
10120 .to_hex()
10121 }
10122
10123 struct StubFetcher {
10126 proof: Option<String>,
10127 calls: Cell<u32>,
10128 }
10129
10130 #[async_trait(?Send)]
10131 impl AncestorFetcher for StubFetcher {
10132 async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
10133 self.calls.set(self.calls.get() + 1);
10134 Ok(FetchedAncestor {
10135 raw_tx: PROOFLESS_RAW_TX.to_string(),
10136 proof: self.proof.clone(),
10137 })
10138 }
10139
10140 async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
10146 true
10147 }
10148 }
10149
10150 async fn engine_with_proofless_output(
10153 fetcher: Option<std::rc::Rc<StubFetcher>>,
10154 ) -> (Engine, String) {
10155 let (beef, txid) = proofless_beef();
10156 let storage = MemoryStorage::new();
10157 storage
10158 .insert_output(&Output {
10159 txid: txid.clone(),
10160 output_index: 0,
10161 output_script: vec![0x76],
10162 satoshis: 1_000_000_000,
10163 topic: "Hello".to_string(),
10164 spent: false,
10165 outputs_consumed: vec![],
10166 consumed_by: vec![],
10167 beef: Some(beef),
10168 block_height: None,
10169 score: Some(1.0),
10170 })
10171 .await
10172 .unwrap();
10173
10174 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10175 managers.insert(
10176 "Hello".into(),
10177 Box::new(MockTopicManager::admitting(vec![0])),
10178 );
10179 let mut engine = Engine::new(
10180 managers,
10181 HashMap::new(),
10182 Box::new(storage),
10183 None,
10184 EngineConfig::default(),
10185 );
10186 if let Some(f) = fetcher {
10187 engine.set_ancestor_fetcher(f);
10188 }
10189 (engine, txid)
10190 }
10191
10192 #[tokio::test]
10193 async fn complete_missing_proofs_noop_without_fetcher() {
10194 let (engine, _txid) = engine_with_proofless_output(None).await;
10196 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10197 assert_eq!(summary, ProofCompletionSummary::default());
10198 }
10199
10200 #[tokio::test]
10201 async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
10202 let (_beef, txid) = proofless_beef();
10203 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
10204 let fetcher = std::rc::Rc::new(StubFetcher {
10205 proof: Some(proof_hex),
10206 calls: Cell::new(0),
10207 });
10208 let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
10209
10210 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10211 assert_eq!(summary.scanned, 1);
10212 assert_eq!(summary.proofless, 1);
10213 assert_eq!(
10214 summary.completed, 1,
10215 "the proofless BEEF should be completed"
10216 );
10217 assert_eq!(fetcher.calls.get(), 1);
10218
10219 let out = engine
10222 .storage()
10223 .find_output(&txid, 0, Some("Hello"), None, true)
10224 .await
10225 .unwrap()
10226 .unwrap();
10227 assert_eq!(out.block_height, Some(850_000));
10228 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
10229 assert!(
10230 beef.find_txid(&txid)
10231 .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
10232 "completed BEEF must prove the target tx"
10233 );
10234
10235 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
10238 assert_eq!(summary2.proofless, 0);
10239 assert_eq!(summary2.completed, 0);
10240 assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
10241 }
10242
10243 #[tokio::test]
10244 async fn complete_missing_proofs_skips_unconfirmed() {
10245 let fetcher = std::rc::Rc::new(StubFetcher {
10248 proof: None,
10249 calls: Cell::new(0),
10250 });
10251 let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
10252 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10253 assert_eq!(summary.proofless, 1);
10254 assert_eq!(summary.completed, 0);
10255 assert_eq!(summary.still_unconfirmed, 1);
10256 assert_eq!(fetcher.calls.get(), 1);
10257 }
10258
10259 async fn engine_with_proofless_output_shared(
10264 fetcher: Option<std::rc::Rc<StubFetcher>>,
10265 ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
10266 let (beef, txid) = proofless_beef();
10267 let storage = std::rc::Rc::new(MemoryStorage::new());
10268 storage
10269 .insert_output(&Output {
10270 txid: txid.clone(),
10271 output_index: 0,
10272 output_script: vec![0x76],
10273 satoshis: 1_000_000_000,
10274 topic: "Hello".to_string(),
10275 spent: false,
10276 outputs_consumed: vec![],
10277 consumed_by: vec![],
10278 beef: Some(beef),
10279 block_height: None,
10280 score: Some(1.0),
10281 })
10282 .await
10283 .unwrap();
10284
10285 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10286 managers.insert(
10287 "Hello".into(),
10288 Box::new(MockTopicManager::admitting(vec![0])),
10289 );
10290 let mut engine = Engine::new(
10291 managers,
10292 HashMap::new(),
10293 Box::new(storage.clone()),
10294 None,
10295 EngineConfig::default(),
10296 );
10297 if let Some(f) = fetcher {
10298 engine.set_ancestor_fetcher(f);
10299 }
10300 (engine, txid, storage)
10301 }
10302
10303 #[tokio::test]
10304 async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
10305 let proof_hex = {
10309 let (_beef, txid) = proofless_beef();
10310 single_leaf_bump_hex(&txid, 850_000)
10311 };
10312 let fetcher = std::rc::Rc::new(StubFetcher {
10313 proof: Some(proof_hex),
10314 calls: Cell::new(0),
10315 });
10316 let (engine, _txid, storage) =
10317 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
10318
10319 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
10320 assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
10321 assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
10322
10323 storage.advance_clock(1800);
10328 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
10329 assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
10330 assert_eq!(summary.completed, 1, "the backstop completes the proof");
10331 assert_eq!(fetcher.calls.get(), 1);
10332 }
10333
10334 #[tokio::test]
10335 async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
10336 let fetcher = std::rc::Rc::new(StubFetcher {
10342 proof: None,
10343 calls: Cell::new(0),
10344 });
10345 let (engine, txid, storage) =
10346 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
10347
10348 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
10351 engine
10352 .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
10353 .await
10354 .unwrap();
10355
10356 storage.advance_clock(1_000_000);
10359 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
10360 assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
10361 assert_eq!(summary.completed, 0);
10362 assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
10363 }
10364
10365 #[tokio::test]
10366 async fn complete_missing_proofs_respects_limit() {
10367 let (beef1, txid1) = proofless_beef();
10369 let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
10371 let (beef2, txid2) = {
10372 use bsv_rs::transaction::{Beef, Transaction};
10373 let tx = Transaction::from_hex(raw2).unwrap();
10374 let id = tx.id();
10375 let mut b = Beef::new();
10376 b.merge_transaction(tx);
10377 (b.to_binary(), id)
10378 };
10379
10380 let storage = MemoryStorage::new();
10381 for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
10382 storage
10383 .insert_output(&Output {
10384 txid: txid.clone(),
10385 output_index: 0,
10386 output_script: vec![0x76],
10387 satoshis: 1000,
10388 topic: "Hello".to_string(),
10389 spent: false,
10390 outputs_consumed: vec![],
10391 consumed_by: vec![],
10392 beef: Some(beef),
10393 block_height: None,
10394 score: Some(1.0),
10395 })
10396 .await
10397 .unwrap();
10398 }
10399
10400 let fetcher = std::rc::Rc::new(StubFetcher {
10401 proof: None,
10402 calls: Cell::new(0),
10403 });
10404 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10405 managers.insert(
10406 "Hello".into(),
10407 Box::new(MockTopicManager::admitting(vec![0])),
10408 );
10409 let mut engine = Engine::new(
10410 managers,
10411 HashMap::new(),
10412 Box::new(storage),
10413 None,
10414 EngineConfig::default(),
10415 );
10416 engine.set_ancestor_fetcher(fetcher.clone());
10417
10418 let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
10419 assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
10420 }
10421
10422 #[tokio::test]
10423 async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
10424 use bsv_rs::transaction::{Beef, MerklePath, Transaction};
10433
10434 let storage = MemoryStorage::new();
10435
10436 let proven_raws = [
10441 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
10442 "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
10443 ];
10444 let mut proven_ids = Vec::new();
10445 for raw in proven_raws {
10446 let mut tx = Transaction::from_hex(raw).unwrap();
10447 let id = tx.id();
10448 tx.merkle_path =
10449 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
10450 let proven_beef = tx.to_beef(true).unwrap();
10453 assert_eq!(
10454 Beef::from_binary(&proven_beef)
10455 .unwrap()
10456 .find_txid(&id)
10457 .map(bsv_rs::transaction::BeefTx::has_proof),
10458 Some(true),
10459 "proven fixture row must carry its proof"
10460 );
10461 proven_ids.push(id.clone());
10462 storage
10463 .insert_output(&Output {
10464 txid: id,
10465 output_index: 0,
10466 output_script: vec![0x76],
10467 satoshis: 1000,
10468 topic: "Hello".to_string(),
10469 spent: false,
10470 outputs_consumed: vec![],
10471 consumed_by: vec![],
10472 beef: Some(proven_beef),
10473 block_height: Some(800_000),
10474 score: Some(1.0),
10475 })
10476 .await
10477 .unwrap();
10478 }
10479
10480 let fetcher = std::rc::Rc::new(StubFetcher {
10481 proof: None,
10482 calls: Cell::new(0),
10483 });
10484 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10485 managers.insert(
10486 "Hello".into(),
10487 Box::new(MockTopicManager::admitting(vec![0])),
10488 );
10489 let mut engine = Engine::new(
10490 managers,
10491 HashMap::new(),
10492 Box::new(storage),
10493 None,
10494 EngineConfig::default(),
10495 );
10496 engine.set_ancestor_fetcher(fetcher.clone());
10497
10498 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10501 assert_eq!(summary.scanned, proven_ids.len());
10502 assert_eq!(summary.proofless, 0);
10503 assert_eq!(summary.completed, 0);
10504 assert_eq!(
10505 summary.already_proven,
10506 proven_ids.len(),
10507 "every already-proven row is marked this pass"
10508 );
10509 assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
10510
10511 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
10514 assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
10515 assert_eq!(summary2.already_proven, 0);
10516 }
10517
10518 #[tokio::test]
10519 async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
10520 use bsv_rs::transaction::{MerklePath, Transaction};
10526
10527 let storage = MemoryStorage::new();
10528
10529 let proven_raws = [
10530 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
10531 ];
10532 for raw in proven_raws {
10533 let mut tx = Transaction::from_hex(raw).unwrap();
10534 let id = tx.id();
10535 tx.merkle_path =
10536 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
10537 let proven_beef = tx.to_beef(true).unwrap();
10538 storage
10539 .insert_output(&Output {
10540 txid: id,
10541 output_index: 0,
10542 output_script: vec![0x76],
10543 satoshis: 1000,
10544 topic: "Hello".to_string(),
10545 spent: false,
10546 outputs_consumed: vec![],
10547 consumed_by: vec![],
10548 beef: Some(proven_beef),
10549 block_height: Some(800_000),
10550 score: Some(1.0),
10551 })
10552 .await
10553 .unwrap();
10554 }
10555
10556 let (proofless, proofless_txid) = proofless_beef();
10558 storage
10559 .insert_output(&Output {
10560 txid: proofless_txid.clone(),
10561 output_index: 0,
10562 output_script: vec![0x76],
10563 satoshis: 1000,
10564 topic: "Hello".to_string(),
10565 spent: false,
10566 outputs_consumed: vec![],
10567 consumed_by: vec![],
10568 beef: Some(proofless),
10569 block_height: None,
10570 score: Some(1.0),
10571 })
10572 .await
10573 .unwrap();
10574
10575 let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
10576 let fetcher = std::rc::Rc::new(StubFetcher {
10577 proof: Some(proof_hex),
10578 calls: Cell::new(0),
10579 });
10580 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10581 managers.insert(
10582 "Hello".into(),
10583 Box::new(MockTopicManager::admitting(vec![0])),
10584 );
10585 let mut engine = Engine::new(
10586 managers,
10587 HashMap::new(),
10588 Box::new(storage),
10589 None,
10590 EngineConfig::default(),
10591 );
10592 engine.set_ancestor_fetcher(fetcher.clone());
10593
10594 let mut completed_total = 0;
10597 for _ in 0..5 {
10598 let s = engine.complete_missing_proofs(1, 0).await.unwrap();
10599 completed_total += s.completed;
10600 if completed_total > 0 {
10601 break;
10602 }
10603 }
10604 assert_eq!(
10605 completed_total, 1,
10606 "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
10607 );
10608
10609 let out = engine
10610 .storage()
10611 .find_output(&proofless_txid, 0, Some("Hello"), None, true)
10612 .await
10613 .unwrap()
10614 .unwrap();
10615 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
10616 assert_eq!(
10617 beef.find_txid(&proofless_txid)
10618 .map(bsv_rs::transaction::BeefTx::has_proof),
10619 Some(true),
10620 "the old proofless tx now carries its proof"
10621 );
10622 }
10623
10624 use crate::storage::StorageError;
10632 use std::rc::Rc;
10633
10634 struct FaultingStorage {
10639 inner: MemoryStorage,
10640 fail_insert_output: Cell<bool>,
10641 fail_find_output: Cell<bool>,
10642 }
10643
10644 impl FaultingStorage {
10645 fn new() -> Rc<Self> {
10646 Rc::new(Self {
10647 inner: MemoryStorage::new(),
10648 fail_insert_output: Cell::new(false),
10649 fail_find_output: Cell::new(false),
10650 })
10651 }
10652 }
10653
10654 #[async_trait(?Send)]
10655 impl Storage for FaultingStorage {
10656 async fn put_deferred_graph(
10657 &self,
10658 record: &crate::gasp::DeferredGraph,
10659 ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
10660 self.inner.put_deferred_graph(record).await
10661 }
10662 async fn find_deferred_graphs(
10663 &self,
10664 host: &str,
10665 topic: &str,
10666 ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
10667 self.inner.find_deferred_graphs(host, topic).await
10668 }
10669 async fn get_deferred_graph(
10670 &self,
10671 host: &str,
10672 topic: &str,
10673 outpoint: &str,
10674 ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
10675 self.inner.get_deferred_graph(host, topic, outpoint).await
10676 }
10677 async fn delete_deferred_graph(
10678 &self,
10679 host: &str,
10680 topic: &str,
10681 outpoint: &str,
10682 ) -> Result<(), StorageError> {
10683 self.inner
10684 .delete_deferred_graph(host, topic, outpoint)
10685 .await
10686 }
10687 async fn record_peer_sync_outcome(
10688 &self,
10689 host: &str,
10690 topic: &str,
10691 success: bool,
10692 ) -> Result<(), StorageError> {
10693 self.inner
10694 .record_peer_sync_outcome(host, topic, success)
10695 .await
10696 }
10697 async fn get_peer_sync_health(
10698 &self,
10699 host: &str,
10700 topic: &str,
10701 ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
10702 self.inner.get_peer_sync_health(host, topic).await
10703 }
10704 async fn record_peer_sync_yield(
10705 &self,
10706 host: &str,
10707 topic: &str,
10708 yielded: bool,
10709 ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
10710 self.inner
10711 .record_peer_sync_yield(host, topic, yielded)
10712 .await
10713 }
10714 async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
10715 if self.fail_insert_output.get() {
10716 return Err(StorageError::Database(
10717 "D1_ERROR: storage overloaded".into(),
10718 ));
10719 }
10720 self.inner.insert_output(output).await
10721 }
10722 async fn delete_output(
10723 &self,
10724 txid: &str,
10725 output_index: u32,
10726 topic: &str,
10727 ) -> Result<(), StorageError> {
10728 self.inner.delete_output(txid, output_index, topic).await
10729 }
10730 async fn mark_utxo_as_spent(
10731 &self,
10732 txid: &str,
10733 output_index: u32,
10734 topic: &str,
10735 ) -> Result<(), StorageError> {
10736 self.inner
10737 .mark_utxo_as_spent(txid, output_index, topic)
10738 .await
10739 }
10740 async fn update_consumed_by(
10741 &self,
10742 txid: &str,
10743 output_index: u32,
10744 topic: &str,
10745 consumed_by: &[Outpoint],
10746 ) -> Result<(), StorageError> {
10747 self.inner
10748 .update_consumed_by(txid, output_index, topic, consumed_by)
10749 .await
10750 }
10751 async fn update_transaction_beef(
10752 &self,
10753 txid: &str,
10754 beef: &[u8],
10755 ) -> Result<(), StorageError> {
10756 self.inner.update_transaction_beef(txid, beef).await
10757 }
10758 async fn insert_applied_transaction(
10759 &self,
10760 tx: &AppliedTransaction,
10761 ) -> Result<(), StorageError> {
10762 self.inner.insert_applied_transaction(tx).await
10763 }
10764 async fn does_applied_transaction_exist(
10765 &self,
10766 tx: &AppliedTransaction,
10767 ) -> Result<bool, StorageError> {
10768 self.inner.does_applied_transaction_exist(tx).await
10769 }
10770 async fn delete_applied_transaction(
10771 &self,
10772 tx: &AppliedTransaction,
10773 ) -> Result<(), StorageError> {
10774 self.inner.delete_applied_transaction(tx).await
10775 }
10776 async fn find_output(
10777 &self,
10778 txid: &str,
10779 output_index: u32,
10780 topic: Option<&str>,
10781 spent: Option<bool>,
10782 include_beef: bool,
10783 ) -> Result<Option<Output>, StorageError> {
10784 if self.fail_find_output.get() {
10785 return Err(StorageError::Database("D1_ERROR: read timed out".into()));
10786 }
10787 self.inner
10788 .find_output(txid, output_index, topic, spent, include_beef)
10789 .await
10790 }
10791 async fn find_outputs_for_transaction(
10792 &self,
10793 txid: &str,
10794 include_beef: bool,
10795 ) -> Result<Vec<Output>, StorageError> {
10796 self.inner
10797 .find_outputs_for_transaction(txid, include_beef)
10798 .await
10799 }
10800 async fn find_utxos_for_topic(
10801 &self,
10802 topic: &str,
10803 since: Option<f64>,
10804 limit: Option<u64>,
10805 include_beef: bool,
10806 ) -> Result<Vec<Output>, StorageError> {
10807 self.inner
10808 .find_utxos_for_topic(topic, since, limit, include_beef)
10809 .await
10810 }
10811 async fn update_last_interaction(
10812 &self,
10813 host: &str,
10814 topic: &str,
10815 since: u64,
10816 ) -> Result<(), StorageError> {
10817 self.inner.update_last_interaction(host, topic, since).await
10818 }
10819 async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
10820 self.inner.get_last_interaction(host, topic).await
10821 }
10822 }
10823
10824 fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
10825 let storage = FaultingStorage::new();
10826 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
10827 managers.insert(
10828 "tm_test".to_string(),
10829 Box::new(MockTopicManager::admitting(admit_indices)),
10830 );
10831 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
10832 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
10833 let engine = Engine::new(
10834 managers,
10835 lookup_services,
10836 Box::new(Rc::clone(&storage)),
10837 None,
10838 EngineConfig::default(),
10839 );
10840 (engine, storage)
10841 }
10842
10843 async fn applied(storage: &FaultingStorage) -> bool {
10844 storage
10845 .does_applied_transaction_exist(&AppliedTransaction {
10846 txid: TEST_TXID.to_string(),
10847 topic: "tm_test".to_string(),
10848 })
10849 .await
10850 .unwrap()
10851 }
10852
10853 #[tokio::test]
10856 async fn clean_submit_is_durable_and_records_applied() {
10857 let (engine, storage) = make_faulting_engine(vec![0]);
10858 let (steak, report) = engine
10859 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10860 .await
10861 .unwrap();
10862 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
10863 assert!(report.is_durable(), "{}", report.summary());
10864 assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
10865 assert!(applied(&storage).await);
10866 assert!(storage
10867 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
10868 .await
10869 .unwrap()
10870 .is_some());
10871 }
10872
10873 #[tokio::test]
10878 async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
10879 let (engine, storage) = make_faulting_engine(vec![0]);
10880 storage.fail_insert_output.set(true);
10881 let (steak, report) = engine
10882 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10883 .await
10884 .unwrap();
10885 assert_eq!(
10886 steak["tm_test"].outputs_to_admit,
10887 vec![0],
10888 "the admission DECISION is unchanged by a write fault"
10889 );
10890 assert!(!report.is_durable());
10891 assert_eq!(report.faults.len(), 1, "{}", report.summary());
10892 assert_eq!(report.faults[0].topic, "tm_test");
10893 assert_eq!(report.faults[0].site, "insert_output");
10894 assert!(report.faults[0].error.contains("D1_ERROR"));
10895 assert!(report.applied_topics.is_empty());
10896 assert!(
10897 !applied(&storage).await,
10898 "a faulted topic must not be recorded as applied — the replay would be a dupe"
10899 );
10900 assert!(storage
10901 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
10902 .await
10903 .unwrap()
10904 .is_none());
10905 }
10906
10907 #[tokio::test]
10911 async fn replay_after_a_fault_reapplies_and_records_applied() {
10912 let (engine, storage) = make_faulting_engine(vec![0]);
10913 storage.fail_insert_output.set(true);
10914 let (_, first) = engine
10915 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10916 .await
10917 .unwrap();
10918 assert!(!first.is_durable());
10919
10920 storage.fail_insert_output.set(false);
10921 let (steak, replay) = engine
10922 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10923 .await
10924 .unwrap();
10925 assert_eq!(
10926 steak["tm_test"].outputs_to_admit,
10927 vec![0],
10928 "re-validated, not deduped"
10929 );
10930 assert!(replay.is_durable(), "{}", replay.summary());
10931 assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
10932 assert!(applied(&storage).await);
10933 assert!(storage
10934 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
10935 .await
10936 .unwrap()
10937 .is_some());
10938
10939 let (steak, third) = engine
10940 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10941 .await
10942 .unwrap();
10943 assert!(
10944 steak["tm_test"].outputs_to_admit.is_empty(),
10945 "now a genuine dupe: nothing admitted"
10946 );
10947 assert!(third.is_durable() && third.applied_topics.is_empty());
10948 }
10949
10950 #[tokio::test]
10956 async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
10957 let (engine, storage) = make_faulting_engine(vec![0]);
10958 let tx = Transaction::from_beef(&test_beef(), None).unwrap();
10961 let source_txid = tx.inputs[0].get_source_txid().unwrap();
10962 let source_vout = tx.inputs[0].source_output_index;
10963 storage
10964 .insert_output(&Output {
10965 txid: source_txid.clone(),
10966 output_index: source_vout,
10967 output_script: vec![0x51],
10968 satoshis: 1,
10969 topic: "tm_test".to_string(),
10970 spent: false,
10971 outputs_consumed: vec![],
10972 consumed_by: vec![],
10973 beef: None,
10974 block_height: None,
10975 score: Some(1.0),
10976 })
10977 .await
10978 .unwrap();
10979
10980 storage.fail_find_output.set(true);
10981 let (_, faulted) = engine
10982 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
10983 .await
10984 .unwrap();
10985 assert!(!faulted.is_durable());
10986 assert!(
10987 faulted.faults.iter().any(|f| f.site == "find_output"),
10988 "{}",
10989 faulted.summary()
10990 );
10991 assert!(
10992 !applied(&storage).await,
10993 "unrecorded: the replay must re-read"
10994 );
10995 storage.fail_find_output.set(false);
10996 assert!(
10997 storage
10998 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
10999 .await
11000 .unwrap()
11001 .is_some(),
11002 "the coin the faulted pass could not see is still there, untouched"
11003 );
11004
11005 let (_, replay) = engine
11006 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11007 .await
11008 .unwrap();
11009 assert!(replay.is_durable(), "{}", replay.summary());
11010 assert!(applied(&storage).await);
11011 assert!(
11014 storage
11015 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
11016 .await
11017 .unwrap()
11018 .is_none(),
11019 "the healthy replay consumed the coin the faulted pass missed"
11020 );
11021 }
11022
11023 #[tokio::test]
11030 async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
11031 use std::cell::RefCell;
11032 use std::rc::Rc;
11033
11034 struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
11035 #[async_trait(?Send)]
11036 impl LookupServiceTrait for CountingLs {
11037 fn admission_mode(&self) -> AdmissionMode {
11038 self.1
11039 }
11040 fn spend_notification_mode(&self) -> SpendNotificationMode {
11041 SpendNotificationMode::None
11042 }
11043 async fn output_admitted_by_topic(
11044 &self,
11045 payload: &OutputAdmittedByTopic,
11046 ) -> Result<(), LookupServiceError> {
11047 let rec = match payload {
11048 OutputAdmittedByTopic::LockingScript {
11049 txid,
11050 output_index,
11051 topic,
11052 ..
11053 } => (txid.clone(), *output_index, topic.clone()),
11054 OutputAdmittedByTopic::WholeTx {
11055 atomic_beef,
11056 output_index,
11057 topic,
11058 ..
11059 } => {
11060 let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
11061 (
11062 beef.atomic_txid
11063 .clone()
11064 .expect("the WholeTx body NAMES its subject"),
11065 *output_index,
11066 topic.clone(),
11067 )
11068 }
11069 };
11070 self.0.borrow_mut().push(rec);
11071 Ok(())
11072 }
11073 async fn output_evicted(
11074 &self,
11075 _txid: &str,
11076 _output_index: u32,
11077 ) -> Result<(), LookupServiceError> {
11078 Ok(())
11079 }
11080 async fn lookup(
11081 &self,
11082 _question: &LookupQuestion,
11083 ) -> Result<LookupResult, LookupServiceError> {
11084 Ok(LookupResult::OutputList(Vec::new()))
11085 }
11086 async fn get_documentation(&self) -> String {
11087 "counting".to_string()
11088 }
11089 async fn get_metadata(&self) -> ServiceMetadata {
11090 ServiceMetadata {
11091 name: "counting-ls".to_string(),
11092 ..Default::default()
11093 }
11094 }
11095 }
11096
11097 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11098 managers.insert(
11099 "tm_test".to_string(),
11100 Box::new(MockTopicManager::admitting(vec![0])),
11101 );
11102 let seen = Rc::new(RefCell::new(Vec::new()));
11103 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
11104 lookup_services.insert(
11105 "ls_whole".to_string(),
11106 Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
11107 );
11108 lookup_services.insert(
11109 "ls_script".to_string(),
11110 Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
11111 );
11112 let engine = Engine::new(
11113 managers,
11114 lookup_services,
11115 Box::new(MemoryStorage::new()),
11116 None,
11117 EngineConfig::default(),
11118 );
11119
11120 let tagged = test_tagged_beef(vec!["tm_test"]);
11121 engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
11122 assert_eq!(seen.borrow().len(), 2, "one admit per service");
11123 let (txid, vout, _) = seen.borrow()[0].clone();
11124 assert_eq!(txid, TEST_TXID);
11125
11126 let (_, report) = engine
11128 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11129 .await
11130 .unwrap();
11131 assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
11132 assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
11133
11134 let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
11136 assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
11137 assert_eq!(r.vouts, vec![vout]);
11138 assert_eq!(seen.borrow().len(), 4);
11139 assert!(seen.borrow()[2..]
11140 .iter()
11141 .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
11142
11143 let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
11145 assert_eq!((r0.outputs, r0.notified), (0, 0));
11146 assert_eq!(seen.borrow().len(), 4);
11147
11148 let r2 = engine
11150 .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
11151 .await
11152 .unwrap();
11153 assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
11154 assert_eq!(seen.borrow().len(), 6);
11155 }
11156
11157 #[tokio::test]
11162 async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
11163 let engine = make_engine(vec![]);
11165 let tagged = test_tagged_beef(vec!["tm_test"]);
11166 let (steak, r1) = engine
11167 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11168 .await
11169 .unwrap();
11170 assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
11171 assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
11172 let (_, r2) = engine
11173 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11174 .await
11175 .unwrap();
11176 assert_eq!(
11177 r2.deduped_topics,
11178 vec!["tm_test".to_string()],
11179 "the phantom row dedups the re-submit"
11180 );
11181 assert!(engine
11182 .forget_phantom_applied(TEST_TXID, "tm_test")
11183 .await
11184 .unwrap());
11185 let (_, r3) = engine
11186 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11187 .await
11188 .unwrap();
11189 assert!(
11190 r3.deduped_topics.is_empty(),
11191 "judged again after the forget"
11192 );
11193 assert!(
11194 !engine
11195 .forget_phantom_applied(TEST_TXID, "tm_other")
11196 .await
11197 .unwrap(),
11198 "no row: nothing to forget"
11199 );
11200
11201 let real = make_engine(vec![0]);
11203 real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
11204 assert!(!real
11205 .forget_phantom_applied(TEST_TXID, "tm_test")
11206 .await
11207 .unwrap());
11208 let (_, r4) = real
11209 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11210 .await
11211 .unwrap();
11212 assert_eq!(
11213 r4.deduped_topics,
11214 vec!["tm_test".to_string()],
11215 "a real admission still dedups"
11216 );
11217 }
11218
11219 const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
11225
11226 const WIDE_HEIGHT: u32 = 800_001;
11228
11229 fn wide_leaves(k: u32) -> Vec<String> {
11231 (0..1u32 << k)
11232 .map(|i| {
11233 let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
11234 id.reverse();
11235 hex::encode(id)
11236 })
11237 .collect()
11238 }
11239
11240 fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
11244 let k = txids.len().trailing_zeros() as usize;
11245 let mut levels = vec![Vec::new(); k];
11246 levels[0] = txids
11247 .iter()
11248 .enumerate()
11249 .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
11250 .collect();
11251 let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
11252 let root = bump.compute_root(None).unwrap();
11253 (bump, root)
11254 }
11255
11256 fn w1_bytes(k: u32) -> (Vec<u8>, String) {
11259 let (bump, root) = wide_bump(&wide_leaves(k));
11260 let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
11261 beef.extend_from_slice(&bump.to_binary());
11262 beef.push(0x00);
11263 (beef, root)
11264 }
11265
11266 fn walk_within_bound(
11270 bytes: Vec<u8>,
11271 root: String,
11272 verify_scripts: bool,
11273 subject: String,
11274 what: &str,
11275 ) -> (Result<(), String>, std::time::Duration) {
11276 let (tx, rx) = std::sync::mpsc::channel();
11277 std::thread::spawn(move || {
11278 let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
11279 tracker.add_root(WIDE_HEIGHT, root);
11280 let runtime = tokio::runtime::Builder::new_current_thread()
11281 .build()
11282 .unwrap();
11283 let started = std::time::Instant::now();
11284 let verdict = runtime.block_on(verify_spv_like_the_reference(
11285 Some(&tracker),
11286 verify_scripts,
11287 &bytes,
11288 &subject,
11289 DoorBudget::DEFAULT,
11290 ));
11291 let verdict = match verdict {
11292 Ok(None) => Ok(()),
11293 Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
11294 Err(e) => Err(e.to_string()),
11295 };
11296 let _ = tx.send((verdict, started.elapsed()));
11297 });
11298 rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
11299 panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
11300 })
11301 }
11302
11303 #[test]
11307 fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
11308 for k in [12u32, 13] {
11309 let (bytes, root) = w1_bytes(k);
11310 let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
11311 let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
11312 println!(
11313 "NL-6e W1 {what}: {verdict:?} in {:.3} s",
11314 took.as_secs_f64()
11315 );
11316 assert_eq!(verdict, Ok(()), "{what}");
11317 }
11318 }
11319
11320 fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
11325 use bsv_rs::script::{LockingScript, UnlockingScript};
11326 use bsv_rs::transaction::{TransactionInput, TransactionOutput};
11327 let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
11328 let spending = |txid: &str| {
11329 let mut input = TransactionInput::new(txid.to_string(), 0);
11330 input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
11331 input
11332 };
11333 let parents: Vec<Transaction> = (0..1u32 << k)
11334 .map(|i| {
11335 let mut tx = Transaction::new();
11336 let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
11338 input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
11339 tx.inputs.push(input);
11340 tx.outputs.push(TransactionOutput::new(1, op_true()));
11341 tx
11342 })
11343 .collect();
11344 let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
11345 let (bump, root) = wide_bump(&ids);
11346 let mut beef = Beef::new();
11347 let at = beef.merge_bump(bump);
11348 for parent in &parents {
11349 beef.merge_raw_tx(parent.to_binary(), Some(at));
11350 }
11351 let mut layer = ids;
11352 while layer.len() > 1 {
11353 layer = layer
11354 .chunks(2)
11355 .map(|pair| {
11356 let mut tx = Transaction::new();
11357 tx.inputs.push(spending(&pair[0]));
11358 tx.inputs.push(spending(&pair[1]));
11359 tx.outputs.push(TransactionOutput::new(0, op_true()));
11360 beef.merge_raw_tx(tx.to_binary(), None);
11361 tx.id()
11362 })
11363 .collect();
11364 }
11365 (beef.to_binary(), root, layer.remove(0))
11366 }
11367
11368 #[test]
11369 fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
11370 for k in [12u32, 13] {
11371 let (bytes, root, subject) = proven_fan_in(k);
11372 let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
11373 let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
11374 println!(
11375 "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
11376 took.as_secs_f64()
11377 );
11378 assert_eq!(verdict, Ok(()), "{what}");
11379 }
11380 }
11381}