1use crate::beef_limits;
10
11use bsv_rs::transaction::{Beef, Transaction};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use tracing::{error, info, warn};
15
16use crate::advertiser::{Advertiser, AdvertiserError};
17use crate::broadcaster::{ArcBroadcaster, Broadcaster};
18use crate::lookup_service::{LookupService, LookupServiceError};
19use crate::storage::{Storage, StorageError};
20use crate::topic_manager::{TopicManager, TopicManagerError};
21use crate::types::*;
22
23#[derive(Debug, Clone)]
28pub enum HistorySelector {
29 Depth(u32),
31}
32
33pub struct EngineConfig {
35 pub hosting_url: Option<String>,
37 pub ship_trackers: Vec<String>,
39 pub slap_trackers: Vec<String>,
41 pub sync_configuration: SyncConfiguration,
43 pub suppress_default_sync_advertisements: bool,
45}
46
47impl Default for EngineConfig {
48 fn default() -> Self {
49 Self {
50 hosting_url: None,
51 ship_trackers: Vec::new(),
52 slap_trackers: Vec::new(),
53 sync_configuration: HashMap::new(),
54 suppress_default_sync_advertisements: true,
55 }
56 }
57}
58
59pub struct Engine {
64 managers: HashMap<String, Box<dyn TopicManager>>,
65 lookup_services: HashMap<String, Box<dyn LookupService>>,
66 storage: Box<dyn Storage>,
67 advertiser: Option<Box<dyn Advertiser>>,
68 broadcaster: Option<Box<dyn Broadcaster>>,
69 arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
70 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
71 gasp_remote_factory: Option<Box<dyn crate::gasp::GASPRemoteFactory>>,
72 ship_lookup_client: Option<Box<dyn crate::ship_lookup::ShipLookupClient>>,
73 ship_bootstrap_peers: HashMap<String, Vec<String>>,
74 ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
79 gasp_ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
80 peer_sync_budget: Option<(SleepFactory, u64)>,
87 graph_budget: Option<(SleepFactory, u32, u64)>,
90 graph_budget_limbs: (u64, u32),
94 finalize_submit_budget: Option<(SleepFactory, u64)>,
97 finalize_gate: crate::gasp::SubmitGate,
101 not_landed: std::cell::RefCell<HashSet<(String, String)>>,
107 landing_guard: Option<LandingGuard>,
110 verify_scripts: bool,
122 walk_budget: DoorBudget,
126 config: EngineConfig,
127}
128
129pub type SleepFuture = std::pin::Pin<Box<dyn std::future::Future<Output = ()>>>;
132
133pub type SleepFactory = std::rc::Rc<dyn Fn(u64) -> SleepFuture>;
138
139pub type LandingGuardFuture =
142 std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
143
144pub type LandingGuard = std::rc::Rc<dyn Fn(&str, &str) -> LandingGuardFuture>;
148
149struct CallBound {
162 sleep: SleepFactory,
163 budget_ms: u64,
164 deadline: std::cell::RefCell<SleepFuture>,
165 due: std::cell::Cell<bool>,
166}
167
168impl CallBound {
169 fn new(sleep: &SleepFactory, budget_ms: u64) -> Self {
170 Self {
171 deadline: std::cell::RefCell::new(sleep(budget_ms)),
172 sleep: sleep.clone(),
173 budget_ms,
174 due: std::cell::Cell::new(false),
175 }
176 }
177
178 fn renew(&self) {
182 *self.deadline.borrow_mut() = (self.sleep)(self.budget_ms);
183 self.due.set(false);
184 }
185
186 fn is_due(&self) -> bool {
189 self.due.get()
190 }
191
192 async fn call<T>(&self, call: impl std::future::Future<Output = T>) -> Option<T> {
194 if self.due.get() {
195 return None;
196 }
197 let mut call = std::pin::pin!(call);
198 std::future::poll_fn(|cx| {
199 if let std::task::Poll::Ready(v) = call.as_mut().poll(cx) {
200 return std::task::Poll::Ready(Some(v));
201 }
202 if self.deadline.borrow_mut().as_mut().poll(cx).is_ready() {
203 self.due.set(true);
204 return std::task::Poll::Ready(None);
205 }
206 std::task::Poll::Pending
207 })
208 .await
209 }
210}
211
212async fn bounded<T>(
214 bound: Option<&CallBound>,
215 call: impl std::future::Future<Output = T>,
216) -> Result<T, String> {
217 match bound {
218 Some(bound) => bound
219 .call(call)
220 .await
221 .ok_or_else(|| format!("no answer within {} ms", bound.budget_ms)),
222 None => Ok(call.await),
223 }
224}
225
226async fn stored<T>(
228 bound: Option<&CallBound>,
229 call: impl std::future::Future<Output = Result<T, StorageError>>,
230) -> Result<T, StorageError> {
231 match bounded(bound, call).await {
232 Ok(answer) => answer,
233 Err(no_answer) => Err(StorageError::Database(no_answer)),
234 }
235}
236
237async fn hooked(
239 bound: Option<&CallBound>,
240 call: impl std::future::Future<Output = Result<(), LookupServiceError>>,
241) -> Result<(), String> {
242 bounded(bound, call).await?.map_err(|e| e.to_string())
243}
244
245const PREDECESSOR_READS: usize = 16;
255
256const PREDECESSOR_READS_PER_SUBMIT: usize = 256;
267
268type Counted = (Steak, MutationReport, HashMap<String, String>);
274
275#[derive(Debug, Clone, Copy)]
279struct Landing {
280 policy: WalkBreachPolicy,
281 subject_walk_ran: bool,
282}
283
284struct NotNow {
285 why: String,
286 carried: Option<String>,
287}
288
289#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
294pub struct ProofCompletionSummary {
295 pub scanned: usize,
297 pub proofless: usize,
299 pub completed: usize,
301 pub still_unconfirmed: usize,
304 pub fetch_failed: usize,
307 pub stitch_failed: usize,
309 pub already_proven: usize,
313}
314
315#[derive(Debug, Default, Clone, Serialize)]
325#[serde(rename_all = "camelCase")]
326pub struct SyncAdvertisementsReport {
327 pub to_create: usize,
330 pub to_revoke: usize,
332 #[serde(skip_serializing_if = "Option::is_none")]
334 pub create_error: Option<String>,
335 #[serde(skip_serializing_if = "Option::is_none")]
340 pub submit_error: Option<String>,
341 pub admitted: std::collections::BTreeMap<String, usize>,
345 #[serde(skip_serializing_if = "Option::is_none")]
349 pub lookup_error: Option<String>,
350 pub revoke_skipped: usize,
354 #[serde(skip_serializing_if = "Option::is_none")]
356 pub revoke_error: Option<String>,
357 #[serde(skip_serializing_if = "Option::is_none")]
359 pub revoke_submit_error: Option<String>,
360}
361
362impl SyncAdvertisementsReport {
363 #[must_use]
367 pub fn ok(&self) -> bool {
368 self.create_error.is_none()
369 && self.submit_error.is_none()
370 && self.lookup_error.is_none()
371 && self.revoke_error.is_none()
372 && self.revoke_submit_error.is_none()
373 }
374
375 #[must_use]
377 pub fn admitted_total(&self) -> usize {
378 self.admitted.values().sum()
379 }
380
381 #[must_use]
391 pub fn effective(&self) -> bool {
392 self.ok() && self.admitted_total() >= self.to_create
393 }
394}
395
396struct TopicValidation {
401 topic: String,
402 is_dupe: bool,
403 previous_coins: Vec<u32>,
405 previous_outputs: Vec<Output>,
407 admittance: AdmittanceInstructions,
408 failed: bool,
409 read_fault: Option<(&'static str, String)>,
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct MutationFault {
432 pub topic: String,
434 pub site: &'static str,
443 pub error: String,
445}
446
447#[derive(Debug, Default, Clone, PartialEq, Eq)]
456pub struct MutationReport {
457 pub faults: Vec<MutationFault>,
458 pub applied_topics: Vec<String>,
459 pub deduped_topics: Vec<String>,
464 pub landed_predecessors: Vec<(String, String)>,
470 pub walk_could_not_run: Option<WalkCouldNotRun>,
477 pub landed_walks_could_not_run: Vec<WalkCouldNotRun>,
483}
484
485#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
487pub struct RenotifyReport {
488 pub outputs: u32,
490 pub notified: u32,
492 pub faults: Vec<String>,
494 pub vouts: Vec<u32>,
496}
497
498impl MutationReport {
499 fn fault(&mut self, topic: &str, site: &'static str, error: String) {
500 self.faults.push(MutationFault {
501 topic: topic.to_string(),
502 site,
503 error,
504 });
505 }
506
507 #[must_use]
510 pub fn is_durable(&self) -> bool {
511 self.faults.is_empty()
512 }
513
514 #[must_use]
517 pub fn summary(&self) -> String {
518 self.faults
519 .iter()
520 .map(|f| {
521 let err: String = f.error.chars().take(120).collect();
522 format!("{}/{}: {}", f.topic, f.site, err)
523 })
524 .collect::<Vec<_>>()
525 .join("; ")
526 }
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub struct DoorBudget {
552 pub memory_limit: usize,
557 pub max_work_bytes: u64,
559 pub sig_check_floor: u64,
569 pub max_memory_bytes: u64,
588}
589
590impl DoorBudget {
591 pub const DEFAULT: DoorBudget = DoorBudget {
668 memory_limit: 128 * 1024,
669 max_work_bytes: 64 * 1024 * 1024,
670 sig_check_floor: 64 * 1024,
671 max_memory_bytes: 48 * 1024 * 1024,
672 };
673}
674
675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
677pub enum DoorLimb {
678 Work,
681 Memory,
683}
684
685type Validated = (
689 Vec<TopicValidation>,
690 Steak,
691 Transaction,
692 String,
693 Option<WalkCouldNotRun>,
694);
695
696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700pub enum WalkLimb {
701 OverWork,
703 OverMemory,
705 InterpreterMemory,
708}
709
710impl WalkLimb {
711 #[must_use]
714 pub fn door_limb(self) -> DoorLimb {
715 match self {
716 WalkLimb::OverWork | WalkLimb::InterpreterMemory => DoorLimb::Work,
717 WalkLimb::OverMemory => DoorLimb::Memory,
718 }
719 }
720
721 #[must_use]
723 pub fn as_str(self) -> &'static str {
724 match self {
725 WalkLimb::OverWork => "over_work",
726 WalkLimb::OverMemory => "over_memory",
727 WalkLimb::InterpreterMemory => "interpreter_memory",
728 }
729 }
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
743pub enum WalkBreachPolicy {
744 #[default]
749 NotNow,
750 NetworkAccepted,
756}
757
758#[derive(Debug, Clone, PartialEq, Eq)]
768pub struct WalkCouldNotRun {
769 pub subject_txid: String,
771 pub at_txid: String,
773 pub subject_judged: bool,
775 pub limb: WalkLimb,
777 pub what: String,
779}
780
781#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
784pub struct WalkStats {
785 pub unproven_txs: usize,
787 pub inputs_executed: usize,
789 pub script_bytes: usize,
791 pub hash_ops: usize,
793 pub sig_ops: usize,
796 pub work_bytes: u64,
798 pub memory_bytes: u64,
802 pub subject_judged: bool,
805}
806
807pub(crate) fn script_census(
814 unlocking: &[u8],
815 locking: &[u8],
816 memory_limit: usize,
817) -> Result<(usize, usize, usize), String> {
818 use bsv_rs::script::op::*;
819 let most_keys = (memory_limit / 33).max(1);
820 let mut hash_ops = 0usize;
821 let mut sig_ops = 0usize;
822 for (label, bytes) in [("unlocking", unlocking), ("locking", locking)] {
823 let script = bsv_rs::script::Script::from_binary(bytes)
824 .map_err(|e| format!("{label} script does not parse: {e}"))?;
825 let chunks = script.chunks();
826 for (i, chunk) in chunks.iter().enumerate() {
827 match chunk.op {
828 OP_RIPEMD160 | OP_SHA1 | OP_SHA256 | OP_HASH160 | OP_HASH256 => hash_ops += 1,
829 OP_CHECKSIG | OP_CHECKSIGVERIFY => sig_ops += 1,
830 OP_CHECKMULTISIG | OP_CHECKMULTISIGVERIFY => {
831 let stated = i
832 .checked_sub(1)
833 .map(|j| chunks[j].op)
834 .and_then(|op| match op {
835 OP_1..=OP_16 => Some((op - OP_1 + 1) as usize),
836 _ => None,
837 });
838 sig_ops += stated.unwrap_or(most_keys);
839 }
840 _ => {}
841 }
842 }
843 }
844 Ok((unlocking.len() + locking.len(), hash_ops, sig_ops))
845}
846
847impl Engine {
848 pub const SYNC_RESPONSE_DEFAULT_LIMIT: u64 = 500;
855 pub const SYNC_RESPONSE_MAX_LIMIT: u64 = 1000;
859
860 pub fn clamp_sync_limit(requested: Option<u64>) -> u64 {
867 requested
868 .unwrap_or(Self::SYNC_RESPONSE_DEFAULT_LIMIT)
869 .min(Self::SYNC_RESPONSE_MAX_LIMIT)
870 }
871
872 pub fn new(
874 managers: HashMap<String, Box<dyn TopicManager>>,
875 lookup_services: HashMap<String, Box<dyn LookupService>>,
876 storage: Box<dyn Storage>,
877 advertiser: Option<Box<dyn Advertiser>>,
878 config: EngineConfig,
879 ) -> Self {
880 Self::with_chain_tracker(
881 managers,
882 lookup_services,
883 storage,
884 advertiser,
885 None,
886 None,
887 config,
888 )
889 }
890
891 pub fn with_chain_tracker(
893 managers: HashMap<String, Box<dyn TopicManager>>,
894 lookup_services: HashMap<String, Box<dyn LookupService>>,
895 storage: Box<dyn Storage>,
896 advertiser: Option<Box<dyn Advertiser>>,
897 broadcaster: Option<Box<dyn Broadcaster>>,
898 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
899 config: EngineConfig,
900 ) -> Self {
901 Self::with_all(
902 managers,
903 lookup_services,
904 storage,
905 advertiser,
906 broadcaster,
907 None,
908 chain_tracker,
909 config,
910 )
911 }
912
913 #[allow(clippy::too_many_arguments)]
915 pub fn with_all(
916 managers: HashMap<String, Box<dyn TopicManager>>,
917 lookup_services: HashMap<String, Box<dyn LookupService>>,
918 storage: Box<dyn Storage>,
919 advertiser: Option<Box<dyn Advertiser>>,
920 broadcaster: Option<Box<dyn Broadcaster>>,
921 arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
922 chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
923 mut config: EngineConfig,
924 ) -> Self {
925 for manager_name in managers.keys() {
928 if manager_name == "tm_ship" {
929 if matches!(
930 config.sync_configuration.get(manager_name),
931 Some(SyncTarget::Disabled)
932 ) {
933 continue;
934 }
935 let mut combined: HashSet<String> = HashSet::new();
936 if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
937 {
938 combined.extend(peers.iter().cloned());
939 }
940 combined.extend(config.ship_trackers.iter().cloned());
941 if !combined.is_empty() {
942 config.sync_configuration.insert(
943 manager_name.clone(),
944 SyncTarget::Peers(combined.into_iter().collect()),
945 );
946 }
947 } else if manager_name == "tm_slap" {
948 if matches!(
949 config.sync_configuration.get(manager_name),
950 Some(SyncTarget::Disabled)
951 ) {
952 continue;
953 }
954 let mut combined: HashSet<String> = HashSet::new();
955 if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
956 {
957 combined.extend(peers.iter().cloned());
958 }
959 combined.extend(config.slap_trackers.iter().cloned());
960 if !combined.is_empty() {
961 config.sync_configuration.insert(
962 manager_name.clone(),
963 SyncTarget::Peers(combined.into_iter().collect()),
964 );
965 }
966 } else if !config.sync_configuration.contains_key(manager_name) {
967 config
968 .sync_configuration
969 .insert(manager_name.clone(), SyncTarget::Ship);
970 }
971 }
972
973 Self {
974 managers,
975 lookup_services,
976 storage,
977 advertiser,
978 broadcaster,
979 arc_broadcaster,
980 chain_tracker,
981 gasp_remote_factory: None,
982 ship_lookup_client: None,
983 ship_bootstrap_peers: HashMap::new(),
984 ancestor_fetcher: None,
985 gasp_ancestor_fetcher: None,
986 peer_sync_budget: None,
987 graph_budget: None,
988 graph_budget_limbs: (
989 crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES,
990 crate::gasp::DEFAULT_GRAPH_BUDGET_NODES,
991 ),
992 finalize_submit_budget: None,
993 finalize_gate: crate::gasp::SubmitGate::default(),
994 not_landed: std::cell::RefCell::new(HashSet::new()),
995 landing_guard: None,
996 verify_scripts: true,
997 walk_budget: DoorBudget::DEFAULT,
998 config,
999 }
1000 }
1001
1002 pub fn set_arc_broadcaster(&mut self, arc: Box<dyn ArcBroadcaster>) {
1007 self.arc_broadcaster = Some(arc);
1008 }
1009
1010 pub fn set_gasp_remote_factory(&mut self, factory: Box<dyn crate::gasp::GASPRemoteFactory>) {
1015 self.gasp_remote_factory = Some(factory);
1016 }
1017
1018 pub fn set_ship_lookup_client(
1021 &mut self,
1022 client: Box<dyn crate::ship_lookup::ShipLookupClient>,
1023 ) {
1024 self.ship_lookup_client = Some(client);
1025 }
1026
1027 pub fn set_ship_bootstrap_peers(&mut self, topic: impl Into<String>, peers: Vec<String>) {
1032 self.ship_bootstrap_peers.insert(topic.into(), peers);
1033 }
1034
1035 pub fn set_ancestor_fetcher(&mut self, fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>) {
1045 self.ancestor_fetcher = Some(fetcher);
1046 }
1047
1048 pub fn set_gasp_ancestor_fetcher(
1050 &mut self,
1051 fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>,
1052 ) {
1053 self.gasp_ancestor_fetcher = Some(fetcher);
1054 }
1055
1056 pub fn set_landing_guard(&mut self, guard: LandingGuard) {
1070 self.landing_guard = Some(guard);
1071 }
1072
1073 pub fn set_peer_sync_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1109 self.peer_sync_budget = Some((sleep, budget_ms));
1110 }
1111
1112 pub fn set_graph_budget(&mut self, sleep: SleepFactory, max_calls: u32, budget_ms: u64) {
1171 self.graph_budget = Some((sleep, max_calls, budget_ms));
1172 }
1173
1174 pub fn set_graph_budget_limbs(&mut self, max_bytes_fetched: u64, max_nodes: u32) {
1195 self.graph_budget_limbs = (max_bytes_fetched, max_nodes);
1196 }
1197
1198 pub fn set_finalize_submit_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1219 self.finalize_submit_budget = Some((sleep, budget_ms));
1220 }
1221
1222 pub fn finalize_submit_gate(&self) -> &crate::gasp::SubmitGate {
1230 &self.finalize_gate
1231 }
1232
1233 pub fn set_script_verification(&mut self, enabled: bool) {
1247 self.verify_scripts = enabled;
1248 }
1249
1250 pub fn script_verification(&self) -> bool {
1253 self.verify_scripts
1254 }
1255
1256 pub fn set_walk_budget(&mut self, budget: DoorBudget) {
1268 self.walk_budget = budget;
1269 }
1270
1271 pub fn walk_budget(&self) -> DoorBudget {
1274 self.walk_budget
1275 }
1276
1277 #[allow(
1312 clippy::unused_async,
1313 reason = "the door's callers await it on an engine; 'scripts only' asks no tracker"
1314 )]
1315 pub async fn verify_scripts_only(
1316 &self,
1317 beef_bytes: &[u8],
1318 subject_txid: &str,
1319 ) -> Result<WalkStats, EngineError> {
1320 crate::script_door::walk(beef_bytes, subject_txid, self.walk_budget)
1321 }
1322
1323 #[allow(
1327 clippy::unused_async,
1328 clippy::unused_self,
1329 reason = "the twin of `verify_scripts_only`"
1330 )]
1331 pub async fn verify_scripts_only_under(
1332 &self,
1333 beef_bytes: &[u8],
1334 subject_txid: &str,
1335 budget: DoorBudget,
1336 ) -> Result<WalkStats, EngineError> {
1337 crate::script_door::walk(beef_bytes, subject_txid, budget)
1338 }
1339
1340 pub async fn submit_validate_only(
1357 &self,
1358 tagged_beef: &TaggedBEEF,
1359 mode: SubmitMode,
1360 ) -> Result<Steak, EngineError> {
1361 let (_validations, steak, _tx, _txid, _could_not_run) = self
1365 .run_validation(
1366 tagged_beef,
1367 mode,
1368 &TopicAdmittanceContext::DRY_RUN,
1369 None,
1370 None,
1371 WalkBreachPolicy::NotNow,
1372 )
1373 .await?;
1374 Ok(steak)
1375 }
1376
1377 pub async fn submit(
1391 &self,
1392 tagged_beef: &TaggedBEEF,
1393 mode: SubmitMode,
1394 ) -> Result<Steak, EngineError> {
1395 let (steak, report) = self.submit_with_report(tagged_beef, mode).await?;
1396 if !report.is_durable() {
1397 warn!(
1398 "submit: {} mutation fault(s) — not durable: {}",
1399 report.faults.len(),
1400 report.summary()
1401 );
1402 }
1403 Ok(steak)
1404 }
1405
1406 pub async fn submit_with_report(
1449 &self,
1450 tagged_beef: &TaggedBEEF,
1451 mode: SubmitMode,
1452 ) -> Result<(Steak, MutationReport), EngineError> {
1453 self.submit_with_report_under(tagged_beef, mode, WalkBreachPolicy::NotNow)
1454 .await
1455 }
1456
1457 pub async fn submit_with_report_under(
1464 &self,
1465 tagged_beef: &TaggedBEEF,
1466 mode: SubmitMode,
1467 policy: WalkBreachPolicy,
1468 ) -> Result<(Steak, MutationReport), EngineError> {
1469 self.submit_bounded(tagged_beef, mode, None, false, policy)
1470 .await
1471 }
1472
1473 async fn submit_bounded(
1479 &self,
1480 tagged_beef: &TaggedBEEF,
1481 mode: SubmitMode,
1482 bound: Option<&CallBound>,
1483 finalize: bool,
1484 policy: WalkBreachPolicy,
1485 ) -> Result<(Steak, MutationReport), EngineError> {
1486 let mut question_reads = 0usize;
1490 self.submit_counted(
1491 tagged_beef,
1492 mode,
1493 bound,
1494 finalize,
1495 None,
1496 &mut question_reads,
1497 policy,
1498 )
1499 .await
1500 .map(|(steak, report, _)| (steak, report))
1501 }
1502
1503 #[allow(clippy::too_many_arguments)]
1517 fn submit_counted<'a>(
1518 &'a self,
1519 tagged_beef: &'a TaggedBEEF,
1520 mode: SubmitMode,
1521 bound: Option<&'a CallBound>,
1522 finalize: bool,
1523 carried: Option<&'a HashSet<String>>,
1524 question_reads: &'a mut usize,
1525 policy: WalkBreachPolicy,
1526 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Counted, EngineError>> + 'a>>
1527 {
1528 Box::pin(async move {
1529 self.submit_counted_inner(
1530 tagged_beef,
1531 mode,
1532 bound,
1533 finalize,
1534 carried,
1535 question_reads,
1536 policy,
1537 )
1538 .await
1539 })
1540 }
1541
1542 #[allow(clippy::too_many_arguments)]
1543 async fn submit_counted_inner(
1544 &self,
1545 tagged_beef: &TaggedBEEF,
1546 mode: SubmitMode,
1547 bound: Option<&CallBound>,
1548 finalize: bool,
1549 walked: Option<&HashSet<String>>,
1550 question_reads: &mut usize,
1551 policy: WalkBreachPolicy,
1552 ) -> Result<Counted, EngineError> {
1553 let carried = walked.is_some();
1556 let context = if carried {
1557 TopicAdmittanceContext::DRY_RUN
1558 } else {
1559 TopicAdmittanceContext::default()
1560 };
1561 let (mut validations, mut steak, tx, txid, could_not_run) = self
1562 .run_validation(tagged_beef, mode, &context, bound, walked, policy)
1563 .await?;
1564 if finalize {
1565 let now = (current_timestamp_ms() / 1000.0) as u64;
1566 for validation in &mut validations {
1567 let manager = &self.managers[&validation.topic];
1568 validation.admittance.outputs_to_admit.retain(|index| {
1569 tx.outputs.get(*index as usize).is_none_or(|output| {
1570 manager
1571 .sync_expiry(&output.locking_script)
1572 .is_none_or(|expiry| expiry > now)
1573 })
1574 });
1575 steak.insert(validation.topic.clone(), validation.admittance.clone());
1576 }
1577 }
1578 let mut report = MutationReport {
1579 walk_could_not_run: could_not_run,
1580 ..MutationReport::default()
1581 };
1582 let subject_named_beef: Vec<u8> =
1592 beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
1593 .ok()
1594 .and_then(|mut b| b.to_binary_atomic(&txid).ok())
1595 .unwrap_or_else(|| tagged_beef.beef.clone());
1596
1597 let mut blocked: HashMap<String, String> = HashMap::new();
1601 let landing = Landing {
1604 policy,
1605 subject_walk_ran: report.walk_could_not_run.is_none(),
1606 };
1607 let waits = self
1608 .successors_waiting(
1609 &tx,
1610 &txid,
1611 &subject_named_beef,
1612 &mut validations,
1613 &mut steak,
1614 tagged_beef.off_chain_values.as_deref(),
1615 mode,
1616 bound,
1617 finalize,
1618 !carried,
1619 &mut blocked,
1620 question_reads,
1621 &mut report.landed_predecessors,
1622 landing,
1623 &mut report.landed_walks_could_not_run,
1624 )
1625 .await;
1626 if carried {
1629 for v in &mut validations {
1630 if v.is_dupe || v.failed || v.read_fault.is_some() || waits.contains_key(&v.topic) {
1631 continue;
1632 }
1633 match self
1634 .judge(
1635 &tx,
1636 &v.topic,
1637 &v.previous_coins,
1638 tagged_beef.off_chain_values.as_deref(),
1639 mode,
1640 &TopicAdmittanceContext::default(),
1641 bound,
1642 )
1643 .await
1644 {
1645 Ok(admittance) => v.admittance = admittance,
1646 Err(e) => {
1647 error!("Error validating topic {} during submit: {e}", v.topic);
1648 v.failed = true;
1649 v.previous_coins.clear();
1650 v.previous_outputs.clear();
1651 v.admittance = AdmittanceInstructions::default();
1652 }
1653 }
1654 steak.insert(v.topic.clone(), v.admittance.clone());
1655 }
1656 }
1657
1658 for v in &validations {
1662 if v.is_dupe {
1663 report.deduped_topics.push(v.topic.clone());
1664 continue;
1665 }
1666 if v.failed {
1672 continue;
1673 }
1674
1675 let topic = &v.topic;
1676 let admittance = &v.admittance;
1677 let faults_before = report.faults.len();
1678
1679 if let Some((read, err)) = &v.read_fault {
1685 report.fault(topic, read, err.clone());
1686 self.hold_unapplied(&txid, topic, "a validation read faulted, nothing written");
1687 continue;
1688 }
1689
1690 if let Some(not_now) = waits.get(topic) {
1694 report.fault(topic, "predecessor_not_landed", not_now.clone());
1695 self.hold_unapplied(
1696 &txid,
1697 topic,
1698 &format!("found no coin and its predecessor {not_now}"),
1699 );
1700 continue;
1701 }
1702
1703 let mut outputs_consumed: Vec<Outpoint> = Vec::new();
1705 let mut stale_coins: Vec<&Output> = Vec::new();
1706
1707 for (coin_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1708 let input_index = v.previous_coins[coin_idx];
1709 if admittance.coins_to_retain.contains(&input_index) {
1710 outputs_consumed
1712 .push(Outpoint::new(&prev_output.txid, prev_output.output_index));
1713 } else {
1714 stale_coins.push(prev_output);
1716 }
1717 }
1718
1719 if let Some(steak_entry) = steak.get_mut(topic) {
1721 steak_entry.coins_removed = Some(
1722 stale_coins
1723 .iter()
1724 .enumerate()
1725 .filter_map(|(i, _)| {
1726 v.previous_coins.get(i).copied()
1728 })
1729 .collect(),
1730 );
1731 }
1732
1733 let mut retained_mark_faulted = false;
1741 for (prev_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1742 if let Err(e) = stored(
1743 bound,
1744 self.storage.mark_utxo_as_spent(
1745 &prev_output.txid,
1746 prev_output.output_index,
1747 topic,
1748 ),
1749 )
1750 .await
1751 {
1752 error!("Error marking UTXO as spent: {e}");
1753 report.fault(topic, "mark_utxo_as_spent", e.to_string());
1754 retained_mark_faulted |= admittance
1755 .coins_to_retain
1756 .contains(&v.previous_coins[prev_idx]);
1757 }
1758
1759 let spending_input_idx = v.previous_coins[prev_idx] as usize;
1761
1762 for ls in self.lookup_services.values() {
1764 let payload = match ls.spend_notification_mode() {
1765 SpendNotificationMode::None => OutputSpent::None {
1766 txid: prev_output.txid.clone(),
1767 output_index: prev_output.output_index,
1768 topic: topic.clone(),
1769 },
1770 SpendNotificationMode::Txid => OutputSpent::Txid {
1771 txid: prev_output.txid.clone(),
1772 output_index: prev_output.output_index,
1773 topic: topic.clone(),
1774 spending_txid: txid.clone(),
1775 },
1776 SpendNotificationMode::Script => {
1777 let input = &tx.inputs[spending_input_idx];
1779 let unlocking_script = input
1780 .unlocking_script
1781 .as_ref()
1782 .map(bsv_rs::UnlockingScript::to_binary)
1783 .unwrap_or_default();
1784 OutputSpent::Script {
1785 txid: prev_output.txid.clone(),
1786 output_index: prev_output.output_index,
1787 topic: topic.clone(),
1788 spending_txid: txid.clone(),
1789 input_index: spending_input_idx as u32,
1790 unlocking_script,
1791 sequence_number: input.sequence,
1792 off_chain_values: tagged_beef.off_chain_values.clone(),
1793 }
1794 }
1795 SpendNotificationMode::WholeTx => OutputSpent::WholeTx {
1796 txid: prev_output.txid.clone(),
1797 output_index: prev_output.output_index,
1798 topic: topic.clone(),
1799 spending_atomic_beef: subject_named_beef.clone(),
1800 off_chain_values: tagged_beef.off_chain_values.clone(),
1801 },
1802 };
1803 if let Err(e) = hooked(bound, ls.output_spent(&payload)).await {
1804 error!("Error notifying lookup service of spent output: {e}");
1805 report.fault(topic, "lookup_service.output_spent", e);
1806 }
1807 }
1808 }
1809 if retained_mark_faulted {
1810 self.hold_unapplied(
1811 &txid,
1812 topic,
1813 "the spent mark of a retained coin faulted, nothing inserted",
1814 );
1815 continue;
1816 }
1817
1818 let mut new_utxos: Vec<Outpoint> = Vec::new();
1822 let mut attempted: Vec<u32> = Vec::new();
1823 let mut insert_faulted = false;
1824
1825 for &output_index in &admittance.outputs_to_admit {
1826 let (script_bytes, sats) =
1827 if let Some(tx_output) = tx.outputs.get(output_index as usize) {
1828 (
1829 tx_output.locking_script.to_binary(),
1830 tx_output.get_satoshis(),
1831 )
1832 } else {
1833 (Vec::new(), 0)
1834 };
1835
1836 let output = Output {
1837 txid: txid.clone(),
1838 output_index,
1839 output_script: script_bytes,
1840 satoshis: sats,
1841 topic: topic.clone(),
1842 spent: false,
1843 outputs_consumed: outputs_consumed.clone(),
1844 consumed_by: Vec::new(),
1845 beef: Some(tagged_beef.beef.clone()),
1846 block_height: None,
1847 score: Some(current_timestamp_ms()),
1848 };
1849
1850 attempted.push(output_index);
1851 if let Err(e) = stored(bound, self.storage.insert_output(&output)).await {
1852 error!("Error inserting output for topic {topic}: {e}");
1853 report.fault(topic, "insert_output", e.to_string());
1854 insert_faulted = true;
1855 break;
1856 }
1857
1858 new_utxos.push(Outpoint::new(&txid, output_index));
1859
1860 for ls in self.lookup_services.values() {
1862 let (ls_script, ls_sats) =
1863 if let Some(tx_out) = tx.outputs.get(output_index as usize) {
1864 (tx_out.locking_script.to_binary(), tx_out.get_satoshis())
1865 } else {
1866 (Vec::new(), 0)
1867 };
1868
1869 let payload = match ls.admission_mode() {
1870 AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
1871 txid: txid.clone(),
1872 output_index,
1873 topic: topic.clone(),
1874 satoshis: ls_sats,
1875 locking_script: ls_script,
1876 off_chain_values: tagged_beef.off_chain_values.clone(),
1877 },
1878 AdmissionMode::WholeTx => OutputAdmittedByTopic::WholeTx {
1879 atomic_beef: subject_named_beef.clone(),
1880 output_index,
1881 topic: topic.clone(),
1882 off_chain_values: tagged_beef.off_chain_values.clone(),
1883 },
1884 };
1885
1886 if let Err(e) = hooked(bound, ls.output_admitted_by_topic(&payload)).await {
1887 error!("Error notifying lookup service: {e}");
1888 report.fault(topic, "lookup_service.output_admitted_by_topic", e);
1889 }
1890 }
1891 }
1892
1893 if insert_faulted {
1904 if let Some(bound) = bound {
1905 bound.renew();
1906 }
1907 self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
1908 .await;
1909 self.hold_unapplied(
1910 &txid,
1911 topic,
1912 &format!(
1913 "an insert faulted: {} previous coin(s) kept, nothing of it held",
1914 stale_coins.len()
1915 ),
1916 );
1917 continue;
1918 }
1919
1920 let due_before_the_delete = bound.is_some_and(CallBound::is_due);
1944 let mut delete_faulted = false;
1945 let mut delete_started = false;
1946 for stale in &stale_coins {
1947 let spent_coin_tx = AppliedTransaction {
1948 txid: stale.txid.clone(),
1949 topic: topic.clone(),
1950 };
1951 if let Err(e) = stored(
1952 bound,
1953 self.storage.insert_applied_transaction(&spent_coin_tx),
1954 )
1955 .await
1956 {
1957 error!(
1958 "Error recording {} as applied before its coin is deleted from topic {topic}: {e}",
1959 stale.txid
1960 );
1961 report.fault(topic, "record_spent_coin_applied", e.to_string());
1962 delete_faulted = true;
1963 continue;
1964 }
1965 self.not_landed
1966 .borrow_mut()
1967 .remove(&(stale.txid.clone(), topic.clone()));
1968 match stored(
1969 bound,
1970 self.storage.find_output(
1971 &stale.txid,
1972 stale.output_index,
1973 Some(topic),
1974 None,
1975 false,
1976 ),
1977 )
1978 .await
1979 {
1980 Ok(Some(stale_output)) => {
1981 delete_started = true;
1982 if let Err(e) = self.delete_utxo_deep(&stale_output, bound).await {
1983 error!("Error deleting stale output for topic {topic}: {e}");
1984 report.fault(topic, "delete_utxo_deep", e.to_string());
1985 delete_faulted = true;
1986 }
1987 }
1988 Ok(None) => {}
1989 Err(e) => {
1990 error!("Error reading stale output for topic {topic}: {e}");
1991 report.fault(topic, "find_output", e.to_string());
1992 delete_faulted = true;
1993 }
1994 }
1995 }
1996
1997 if delete_faulted {
2028 let unanswered = !due_before_the_delete && bound.is_some_and(CallBound::is_due);
2029 if let Some(bound) = bound {
2030 bound.renew();
2031 }
2032 let mut all_held = !unanswered && !delete_started;
2033 for stale in &stale_coins {
2034 if !all_held {
2035 break;
2036 }
2037 let read = stored(
2038 bound,
2039 self.storage.find_output(
2040 &stale.txid,
2041 stale.output_index,
2042 Some(topic),
2043 None,
2044 false,
2045 ),
2046 )
2047 .await;
2048 if !matches!(read, Ok(Some(_))) {
2049 all_held = false;
2050 break;
2051 }
2052 }
2053 if all_held {
2054 self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
2055 .await;
2056 }
2057 self.hold_unapplied(
2058 &txid,
2059 topic,
2060 if all_held {
2061 "no delete of a stale coin was started: the coins are held, its outputs taken out"
2062 } else if unanswered {
2063 "the delete of a stale coin did not answer: nothing undone, its outputs stay"
2064 } else if delete_started {
2065 "the delete of a stale coin faulted: nothing undone, its outputs stay"
2066 } else {
2067 "no delete was started and a stale coin was not read back held: nothing undone, its outputs stay"
2068 },
2069 );
2070 continue;
2071 }
2072
2073 for consumed_outpoint in &outputs_consumed {
2075 let consumed_output = match stored(
2076 bound,
2077 self.storage.find_output(
2078 &consumed_outpoint.txid,
2079 consumed_outpoint.output_index,
2080 Some(topic),
2081 None,
2082 false,
2083 ),
2084 )
2085 .await
2086 {
2087 Ok(Some(o)) => o,
2088 Ok(None) => continue,
2089 Err(e) => {
2090 error!("Error reading consumed output for topic {topic}: {e}");
2091 report.fault(topic, "find_output", e.to_string());
2092 continue;
2093 }
2094 };
2095 {
2096 let mut new_consumed_by = consumed_output.consumed_by.clone();
2097 for new_utxo in &new_utxos {
2098 if !new_consumed_by.iter().any(|c| {
2099 c.txid == new_utxo.txid && c.output_index == new_utxo.output_index
2100 }) {
2101 new_consumed_by.push(new_utxo.clone());
2102 }
2103 }
2104 if let Err(e) = stored(
2105 bound,
2106 self.storage.update_consumed_by(
2107 &consumed_outpoint.txid,
2108 consumed_outpoint.output_index,
2109 topic,
2110 &new_consumed_by,
2111 ),
2112 )
2113 .await
2114 {
2115 error!("Error updating consumedBy: {e}");
2116 report.fault(topic, "update_consumed_by", e.to_string());
2117 }
2118 }
2119 }
2120
2121 if report.faults.len() > faults_before {
2125 self.hold_unapplied(
2126 &txid,
2127 topic,
2128 &format!(
2129 "{} mutation fault(s); a replay of these bytes re-applies",
2130 report.faults.len() - faults_before
2131 ),
2132 );
2133 continue;
2134 }
2135 let tx_record = AppliedTransaction {
2136 txid: txid.clone(),
2137 topic: topic.clone(),
2138 };
2139 match stored(bound, self.storage.insert_applied_transaction(&tx_record)).await {
2140 Ok(()) => {
2141 report.applied_topics.push(topic.clone());
2142 self.not_landed
2143 .borrow_mut()
2144 .remove(&(txid.clone(), topic.clone()));
2145 }
2146 Err(e) => {
2147 error!("Error inserting applied transaction for topic {topic}: {e}");
2148 report.fault(topic, "insert_applied_transaction", e.to_string());
2149 self.not_landed
2150 .borrow_mut()
2151 .insert((txid.clone(), topic.clone()));
2152 }
2153 }
2154 }
2155
2156 Ok((steak, report, blocked))
2157 }
2158
2159 #[allow(clippy::too_many_arguments)]
2211 async fn successors_waiting(
2212 &self,
2213 tx: &Transaction,
2214 txid: &str,
2215 subject_named_beef: &[u8],
2216 validations: &mut [TopicValidation],
2217 steak: &mut Steak,
2218 off_chain_values: Option<&[u8]>,
2219 mode: SubmitMode,
2220 bound: Option<&CallBound>,
2221 finalize: bool,
2222 lands: bool,
2223 blocked: &mut HashMap<String, String>,
2224 question_reads: &mut usize,
2225 landed: &mut Vec<(String, String)>,
2226 landing: Landing,
2227 landed_breaches: &mut Vec<WalkCouldNotRun>,
2228 ) -> HashMap<String, String> {
2229 let mut waits = HashMap::new();
2230 let spent: Vec<(String, u32)> = tx
2231 .inputs
2232 .iter()
2233 .filter_map(|input| {
2234 let source = input.get_source_txid().ok()?;
2235 (!source.is_empty()).then_some((source, input.source_output_index))
2236 })
2237 .collect();
2238 if spent.is_empty() {
2239 return waits;
2240 }
2241 let found_no_coin = |v: &TopicValidation| {
2242 !v.is_dupe && !v.failed && v.read_fault.is_none() && v.previous_outputs.is_empty()
2243 };
2244 for v in validations.iter_mut() {
2245 if !found_no_coin(v) {
2246 continue;
2247 }
2248 let Some(manager) = self.managers.get(&v.topic) else {
2249 continue;
2250 };
2251 let topic = v.topic.clone();
2252 let mut subject_names: Option<HashSet<String>> = None;
2256 loop {
2257 let admits = !v.admittance.outputs_to_admit.is_empty();
2258 if admits && subject_names.is_none() {
2259 subject_names = Some(
2260 self.named_history(manager.as_ref(), subject_named_beef, bound)
2261 .await,
2262 );
2263 }
2264 let remembered = spent.iter().find(|(source, vout)| {
2265 (!admits
2266 || subject_names
2267 .as_ref()
2268 .is_some_and(|named| named.contains(&format!("{source}.{vout}"))))
2269 && self
2270 .not_landed
2271 .borrow()
2272 .contains(&(source.clone(), topic.clone()))
2273 });
2274 if let Some((predecessor, _)) = remembered {
2275 waits.insert(topic.clone(), format!("{predecessor} has not landed"));
2276 break;
2277 }
2278 if finalize {
2279 break;
2280 }
2281 let Some(not_now) = self
2282 .unlanded_predecessor(
2283 tx,
2284 txid,
2285 subject_named_beef,
2286 &topic,
2287 mode,
2288 admits,
2289 &mut subject_names,
2290 question_reads,
2291 bound,
2292 )
2293 .await
2294 else {
2295 break;
2296 };
2297 let Some(carried) = not_now.carried else {
2298 waits.insert(topic.clone(), not_now.why);
2299 break;
2300 };
2301 if !lands {
2302 blocked.insert(topic.clone(), carried);
2303 waits.insert(topic.clone(), not_now.why);
2304 break;
2305 }
2306 if !self.lands_without_values(&topic) {
2307 waits.insert(
2308 topic.clone(),
2309 format!(
2310 "{}; not landed first from the BEEF: the topic's manager or a lookup service reads off-chain values, and only the predecessor's own submit carries them",
2311 not_now.why
2312 ),
2313 );
2314 break;
2315 }
2316 let rejudge = 1 + spent.len();
2318 if *question_reads + rejudge > PREDECESSOR_READS_PER_SUBMIT {
2319 waits.insert(
2320 topic.clone(),
2321 format!(
2322 "{}; its landing and the re-judgement after it do not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads",
2323 not_now.why
2324 ),
2325 );
2326 break;
2327 }
2328 *question_reads += rejudge;
2329 match self
2330 .land_carried(
2331 subject_named_beef,
2332 txid,
2333 &carried,
2334 &topic,
2335 mode,
2336 bound,
2337 question_reads,
2338 landed,
2339 landing,
2340 landed_breaches,
2341 )
2342 .await
2343 {
2344 Ok(()) => {
2345 *v = self
2346 .validate_topic(
2347 tx,
2348 txid,
2349 &topic,
2350 off_chain_values,
2351 mode,
2352 &TopicAdmittanceContext::default(),
2353 bound,
2354 )
2355 .await;
2356 steak.insert(topic.clone(), v.admittance.clone());
2357 if !found_no_coin(v) {
2358 break;
2359 }
2360 }
2361 Err(did_not_land) => {
2362 *question_reads -= rejudge;
2364 waits.insert(
2365 topic.clone(),
2366 format!(
2367 "{}; submitted first from the BEEF, it did not land ({did_not_land})",
2368 not_now.why
2369 ),
2370 );
2371 break;
2372 }
2373 }
2374 }
2375 if !finalize
2384 && waits.contains_key(&topic)
2385 && *question_reads < PREDECESSOR_READS_PER_SUBMIT
2386 {
2387 *question_reads += 1;
2388 if let Ok(outputs) = stored(
2389 bound,
2390 self.storage.find_outputs_for_transaction(txid, false),
2391 )
2392 .await
2393 {
2394 if outputs.iter().any(|o| o.topic == topic) {
2395 info!("topic {topic}: {txid} holds its outputs already; its replay finishes it");
2396 waits.remove(&topic);
2397 blocked.remove(&topic);
2398 }
2399 }
2400 }
2401 }
2402 waits
2403 }
2404
2405 #[allow(clippy::too_many_arguments)]
2438 async fn land_carried(
2439 &self,
2440 beef_bytes: &[u8],
2441 subject: &str,
2442 predecessor: &str,
2443 topic: &str,
2444 mode: SubmitMode,
2445 bound: Option<&CallBound>,
2446 question_reads: &mut usize,
2447 landed: &mut Vec<(String, String)>,
2448 landing: Landing,
2449 landed_breaches: &mut Vec<WalkCouldNotRun>,
2450 ) -> Result<(), String> {
2451 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
2452 .map_err(|e| format!("the BEEF could not be read again ({e})"))?;
2453 let mode = match mode {
2454 SubmitMode::CurrentTx => SubmitMode::HistoricalTx,
2455 other => other,
2456 };
2457 let walks = mode != SubmitMode::HistoricalTxNoSpv;
2458 let mut walked: HashSet<String> = if walks && landing.subject_walk_ran {
2463 walk_cover(&beef, subject)
2464 } else {
2465 HashSet::new()
2466 };
2467 let mut stack = vec![predecessor.to_string()];
2468 let mut guarded: HashSet<String> = HashSet::new();
2469 while let Some(next) = stack.last().cloned() {
2470 let body = beef
2471 .find_atomic_transaction(&next)
2472 .ok_or_else(|| format!("{next}: its body is not in the BEEF"))?;
2473 if let Some(guard) = &self.landing_guard {
2476 if guarded.insert(next.clone()) {
2477 bounded(bound, guard(&next, topic))
2478 .await
2479 .and_then(|answer| answer)
2480 .map_err(|why| format!("{next}: refused by the landing guard ({why})"))?;
2481 }
2482 }
2483 let cost = 1 + 2 * body.inputs.len();
2486 if *question_reads + cost > PREDECESSOR_READS_PER_SUBMIT {
2487 return Err(format!(
2488 "{next}: its submit does not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2489 ));
2490 }
2491 *question_reads += cost;
2492 let atomic = body
2493 .to_atomic_beef(true)
2494 .map_err(|e| format!("{next}: its BEEF could not be built ({e})"))?;
2495 let tagged = TaggedBEEF::new(atomic, vec![topic.to_string()]);
2496 let (_, report, blocked) = match self
2497 .submit_counted(
2498 &tagged,
2499 mode,
2500 bound,
2501 false,
2502 Some(&walked),
2503 question_reads,
2504 landing.policy,
2505 )
2506 .await
2507 {
2508 Ok(counted) => counted,
2509 Err(EngineError::WalkCouldNotRun(stop)) => {
2512 let why = format!("{next}: {}", EngineError::WalkCouldNotRun(stop.clone()));
2513 landed_breaches.push(stop);
2514 return Err(why);
2515 }
2516 Err(e) => return Err(format!("{next}: {e}")),
2517 };
2518 if let Some(stop) = &report.walk_could_not_run {
2521 landed_breaches.push(stop.clone());
2522 } else if walks && !walked.contains(&next) {
2523 walked.extend(walk_cover(&beef, &next));
2524 }
2525 let landed_now = report.applied_topics.iter().any(|t| t == topic);
2526 if report.is_durable()
2527 && (landed_now || report.deduped_topics.iter().any(|t| t == topic))
2528 {
2529 info!("topic {topic}: {next} landed first from its successor's BEEF");
2530 if landed_now {
2531 landed.push((next.clone(), topic.to_string()));
2532 }
2533 stack.pop();
2534 continue;
2535 }
2536 match blocked.get(topic) {
2537 Some(under) if !stack.contains(under) => stack.push(under.clone()),
2538 _ if report.is_durable() => return Err(format!("{next}: the manager failed it")),
2539 _ => return Err(format!("{next}: {}", report.summary())),
2540 }
2541 }
2542 Ok(())
2543 }
2544
2545 fn hold_unapplied(&self, txid: &str, topic: &str, why: &str) {
2548 warn!("topic {topic}: {txid}: {why}; applied_transactions NOT recorded (bsv-low #559)");
2549 self.not_landed
2550 .borrow_mut()
2551 .insert((txid.to_string(), topic.to_string()));
2552 }
2553
2554 async fn undo_inserts(
2562 &self,
2563 txid: &str,
2564 topic: &str,
2565 output_indices: &[u32],
2566 bound: Option<&CallBound>,
2567 report: &mut MutationReport,
2568 ) {
2569 for &output_index in output_indices {
2570 let row = stored(
2571 bound,
2572 self.storage
2573 .find_output(txid, output_index, Some(topic), None, false),
2574 )
2575 .await;
2576 let undone = match row {
2577 Ok(Some(row)) if !row.spent && row.consumed_by.is_empty() => {
2578 stored(bound, self.storage.delete_output(txid, output_index, topic)).await
2579 }
2580 Ok(_) => Ok(()),
2581 Err(e) => Err(e),
2582 };
2583 if let Err(e) = undone {
2584 error!("Error taking {txid}.{output_index} out of topic {topic} again: {e}");
2585 report.fault(topic, "undo_insert_output", e.to_string());
2586 }
2587 }
2588 }
2589
2590 #[allow(clippy::too_many_arguments)]
2706 async fn unlanded_predecessor(
2707 &self,
2708 tx: &Transaction,
2709 txid: &str,
2710 beef_bytes: &[u8],
2711 topic: &str,
2712 mode: SubmitMode,
2713 admits: bool,
2714 subject_names: &mut Option<HashSet<String>>,
2715 question_reads: &mut usize,
2716 bound: Option<&CallBound>,
2717 ) -> Option<NotNow> {
2718 let manager = self.managers.get(topic)?;
2719 let wait = |why: String| Some(NotNow { why, carried: None });
2720 let starts: Vec<(String, u32)> = tx
2722 .inputs
2723 .iter()
2724 .filter_map(|input| {
2725 let source = input.get_source_txid().ok()?;
2726 (!source.is_empty()).then_some((source, input.source_output_index))
2727 })
2728 .collect();
2729 let mut spent_of: HashMap<String, HashSet<u32>> = HashMap::new();
2732 let mut spenders: HashMap<String, Vec<(String, u32)>> = HashMap::new();
2733 let mut ask: Vec<String> = Vec::new();
2734 for (source, vout) in &starts {
2735 if admits
2736 && subject_names
2737 .as_ref()
2738 .is_some_and(|named| !named.contains(&format!("{source}.{vout}")))
2739 {
2740 continue;
2741 }
2742 spent_of.entry(source.clone()).or_default().insert(*vout);
2743 spenders
2744 .entry(source.clone())
2745 .or_default()
2746 .push((txid.to_string(), *vout));
2747 ask.push(source.clone());
2748 }
2749 if ask.is_empty() {
2752 return None;
2753 }
2754 let direct: HashSet<String> = ask.iter().cloned().collect();
2755 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS).ok()?;
2756 let bodies: HashMap<String, (&Transaction, bool)> = beef
2759 .txs
2760 .iter()
2761 .filter_map(|btx| {
2762 btx.tx()
2763 .map(|body| (btx.txid(), (body, btx.bump_index().is_some())))
2764 })
2765 .collect();
2766 let mut names_over: HashMap<String, HashSet<String>> = HashMap::new();
2768 let mut reads = 0usize;
2770 let mut asked: HashSet<String> = HashSet::new();
2771 while let Some(candidate) = ask.pop() {
2772 if candidate.is_empty() || !asked.insert(candidate.clone()) {
2773 continue;
2774 }
2775 let spent_outputs = spent_of.get(&candidate).cloned().unwrap_or_default();
2776 let body = bodies.get(&candidate).copied();
2779 if body.is_none() {
2780 if !direct.contains(&candidate) {
2781 continue;
2782 }
2783 if subject_names.is_none() {
2784 *subject_names = Some(
2785 self.named_history(manager.as_ref(), beef_bytes, bound)
2786 .await,
2787 );
2788 }
2789 let is_named = subject_names.as_ref().is_some_and(|named| {
2790 spent_outputs
2791 .iter()
2792 .any(|vout| named.contains(&format!("{candidate}.{vout}")))
2793 });
2794 if !is_named {
2795 continue;
2796 }
2797 }
2798 let proven = body.is_some_and(|(_, proven)| proven);
2799 let mut spent = 0usize;
2802 let out_of = |spent: usize| !proven && reads + spent >= PREDECESSOR_READS;
2803 let out_of_reads = || {
2804 wait(format!(
2805 "{candidate} is not known to have landed: the question ran out of its {PREDECESSOR_READS} reads"
2806 ))
2807 };
2808 let out_of_allowance = || {
2811 wait(format!(
2812 "{candidate} is not known to have landed: the question ran out of the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2813 ))
2814 };
2815 let unread = |e: StorageError| {
2816 wait(format!(
2817 "{candidate} is not known to have landed: the store could not say ({e})"
2818 ))
2819 };
2820 let carried = |why: String| {
2821 Some(NotNow {
2822 why,
2823 carried: Some(candidate.clone()),
2824 })
2825 };
2826 let record = AppliedTransaction {
2827 txid: candidate.clone(),
2828 topic: topic.to_string(),
2829 };
2830 if out_of(spent) {
2833 return out_of_reads();
2834 }
2835 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2836 return out_of_allowance();
2837 }
2838 spent += 1;
2839 *question_reads += 1;
2840 match stored(bound, self.storage.does_applied_transaction_exist(&record)).await {
2841 Ok(false) => {}
2842 Ok(true) => continue,
2843 Err(e) => return unread(e),
2844 }
2845 if out_of(spent) {
2846 return out_of_reads();
2847 }
2848 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2849 return out_of_allowance();
2850 }
2851 spent += 1;
2852 *question_reads += 1;
2853 match stored(
2854 bound,
2855 self.storage.find_outputs_for_transaction(&candidate, false),
2856 )
2857 .await
2858 {
2859 Ok(outputs) if outputs.iter().all(|o| o.topic != topic) => {}
2860 Ok(_) => continue,
2861 Err(e) => return unread(e),
2862 }
2863 let Some((body, _)) = body else {
2866 return wait(format!(
2867 "{candidate} has not landed (the manager names it as history; the BEEF does not carry it)"
2868 ));
2869 };
2870 for input in &body.inputs {
2871 let source = input.get_source_txid().unwrap_or_default();
2872 if source.is_empty() {
2873 continue;
2874 }
2875 if out_of(spent) {
2876 return out_of_reads();
2877 }
2878 if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2879 return out_of_allowance();
2880 }
2881 spent += 1;
2882 *question_reads += 1;
2883 let coin = stored(
2884 bound,
2885 self.storage.find_output(
2886 &source,
2887 input.source_output_index,
2888 Some(topic),
2889 None,
2890 false,
2891 ),
2892 )
2893 .await;
2894 match coin {
2895 Ok(Some(_)) => return carried(format!("{candidate} has not landed")),
2896 Ok(None) => {
2899 spent_of
2900 .entry(source.clone())
2901 .or_default()
2902 .insert(input.source_output_index);
2903 spenders
2904 .entry(source.clone())
2905 .or_default()
2906 .push((candidate.clone(), input.source_output_index));
2907 ask.push(source);
2908 }
2909 Err(e) => return unread(e),
2910 }
2911 }
2912 let mut named_outputs: HashSet<u32> = HashSet::new();
2916 for (spender, vout) in spenders.get(&candidate).cloned().unwrap_or_default() {
2917 let names = if spender == txid {
2918 if subject_names.is_none() {
2919 *subject_names = Some(
2920 self.named_history(manager.as_ref(), beef_bytes, bound)
2921 .await,
2922 );
2923 }
2924 subject_names.as_ref()
2925 } else {
2926 if !names_over.contains_key(&spender) {
2927 let names = match beef
2928 .find_atomic_transaction(&spender)
2929 .and_then(|spender| spender.to_atomic_beef(true).ok())
2930 {
2931 Some(spender_beef) => {
2932 self.named_history(manager.as_ref(), &spender_beef, bound)
2933 .await
2934 }
2935 None => HashSet::new(),
2936 };
2937 names_over.insert(spender.clone(), names);
2938 }
2939 names_over.get(&spender)
2940 };
2941 if names.is_some_and(|named| named.contains(&format!("{candidate}.{vout}"))) {
2942 named_outputs.insert(vout);
2943 }
2944 }
2945 if !named_outputs.is_empty() {
2946 let judged = beef
2951 .find_atomic_transaction(&candidate)
2952 .unwrap_or_else(|| body.clone());
2953 let verdict = bounded(
2954 bound,
2955 manager.identify_admissible_outputs(
2956 &judged,
2957 &[],
2958 None,
2959 mode,
2960 &TopicAdmittanceContext::DRY_RUN,
2961 ),
2962 )
2963 .await;
2964 match verdict {
2965 Ok(Ok(admittance)) => {
2966 if admittance
2967 .outputs_to_admit
2968 .iter()
2969 .any(|vout| named_outputs.contains(vout))
2970 {
2971 return carried(format!(
2972 "{candidate} has not landed (the manager names the output spent from it and would admit it with no coin: an opener)"
2973 ));
2974 }
2975 }
2976 Ok(Err(TopicManagerError::NoAdmissibleOutputs(_))) => {}
2977 Ok(Err(e)) => {
2978 return wait(format!(
2979 "{candidate} is not known to have landed: the manager could not judge it ({e})"
2980 ))
2981 }
2982 Err(no_answer) => {
2983 return wait(format!(
2984 "{candidate} is not known to have landed: the manager did not answer ({no_answer})"
2985 ))
2986 }
2987 }
2988 }
2989 if !proven {
2990 reads += spent;
2991 }
2992 }
2993 None
2994 }
2995
2996 async fn named_history(
3002 &self,
3003 manager: &dyn TopicManager,
3004 beef_bytes: &[u8],
3005 bound: Option<&CallBound>,
3006 ) -> HashSet<String> {
3007 match bounded(bound, manager.identify_needed_inputs(beef_bytes, None)).await {
3008 Ok(Ok(named)) => named.iter().map(Outpoint::to_graph_id).collect(),
3009 Ok(Err(e)) => {
3010 warn!("the predecessor question: the manager could not name its inputs: {e}");
3011 HashSet::new()
3012 }
3013 Err(no_answer) => {
3014 warn!("the predecessor question: the manager did not name its inputs: {no_answer}");
3015 HashSet::new()
3016 }
3017 }
3018 }
3019
3020 async fn verify_beef_linear(
3055 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3056 beef_bytes: &[u8],
3057 subject_txid: &str,
3058 trusted: &HashSet<String>,
3059 budget: DoorBudget,
3060 ) -> Result<Option<WalkCouldNotRun>, EngineError> {
3061 let spv =
3063 |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3064
3065 let mut trace = crate::script_door::WalkTrace::default();
3070 match crate::script_door::walk_trusting(
3071 beef_bytes,
3072 subject_txid,
3073 budget,
3074 trusted,
3075 &mut trace,
3076 ) {
3077 Ok(_) => {}
3078 Err(refused @ EngineError::ScriptVerificationFailed { .. }) => return Err(refused),
3080 Err(EngineError::ScriptWalkInconclusive { reason, .. }) => return Err(spv(reason)),
3083 Err(EngineError::ScriptWalkOverBudget {
3092 at_txid,
3093 subject_judged,
3094 limb,
3095 what,
3096 }) => {
3097 let limb = match limb {
3098 DoorLimb::Memory => WalkLimb::OverMemory,
3099 DoorLimb::Work if trace.interpreter_tripped => WalkLimb::InterpreterMemory,
3100 DoorLimb::Work => WalkLimb::OverWork,
3101 };
3102 let reached: Vec<String> = trace
3110 .proven
3111 .iter()
3112 .chain(&trace.proven_sources)
3113 .cloned()
3114 .collect();
3115 Self::check_reached_roots(chain_tracker, beef_bytes, &reached).await?;
3116 return Ok(Some(WalkCouldNotRun {
3117 subject_txid: subject_txid.to_string(),
3118 at_txid,
3119 subject_judged,
3120 limb,
3121 what,
3122 }));
3123 }
3124 Err(other) => return Err(other),
3125 }
3126
3127 Self::check_reached_roots(chain_tracker, beef_bytes, &trace.proven).await?;
3134 Ok(None)
3135 }
3136
3137 async fn check_reached_roots(
3141 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3142 beef_bytes: &[u8],
3143 reached: &[String],
3144 ) -> Result<(), EngineError> {
3145 let spv =
3146 |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3147 if reached.is_empty() {
3148 return Ok(());
3149 }
3150 let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
3151 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3152 let proven_in = proven_index(&beef);
3153 let mut checked: HashSet<usize> = HashSet::new();
3154 for txid in reached {
3155 let Some(&at) = proven_in.get(txid.as_str()) else {
3156 return Err(spv(format!("transaction {txid} is not in the BEEF")));
3157 };
3158 if !checked.insert(at) {
3159 continue; }
3161 let mp = &beef.bumps[at];
3162 let root = mp
3163 .compute_root(Some(txid))
3164 .map_err(|e| spv(format!("invalid merkle path for transaction {txid}: {e}")))?;
3165 if let Some(tracker) = chain_tracker {
3166 match tracker
3167 .is_valid_root_for_height(&root, mp.block_height)
3168 .await
3169 {
3170 Ok(true) => {}
3171 Ok(false) => {
3172 return Err(EngineError::SpvError(format!(
3173 "Invalid merkle path for transaction {txid}: root {root} is not valid for block height {}",
3174 mp.block_height
3175 )));
3176 }
3177 Err(e) => {
3178 return Err(EngineError::SpvError(format!(
3179 "Chain tracker error at height {}: {e}",
3180 mp.block_height
3181 )));
3182 }
3183 }
3184 }
3185 }
3186 Ok(())
3187 }
3188
3189 async fn verify_spv_structurally(
3203 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3204 beef_bytes: &[u8],
3205 ) -> Result<(), EngineError> {
3206 let Some(chain_tracker) = chain_tracker else {
3207 return Ok(());
3208 };
3209 let roots = beef_limits::structure_roots(beef_bytes)
3210 .map_err(|r| EngineError::SpvError(format!("BEEF structure refused: {r}")))?;
3211 let mut asked: HashSet<(u64, &str)> = HashSet::new();
3212 for (height, root) in &roots {
3213 if !asked.insert((*height, root.as_str())) {
3214 continue;
3215 }
3216 let Ok(height) = u32::try_from(*height) else {
3217 return Err(EngineError::SpvError(format!(
3218 "Merkle root {root} invalid for block height {height}"
3219 )));
3220 };
3221 match chain_tracker.is_valid_root_for_height(root, height).await {
3222 Ok(true) => {}
3223 Ok(false) => {
3224 return Err(EngineError::SpvError(format!(
3225 "Merkle root {root} invalid for block height {height}"
3226 )));
3227 }
3228 Err(e) => {
3229 return Err(EngineError::SpvError(format!(
3230 "Chain tracker error at height {height}: {e}"
3231 )));
3232 }
3233 }
3234 }
3235 Ok(())
3236 }
3237
3238 #[allow(clippy::too_many_arguments)]
3243 async fn validate_topic(
3244 &self,
3245 tx: &Transaction,
3246 txid: &str,
3247 topic: &str,
3248 off_chain_values: Option<&[u8]>,
3249 mode: SubmitMode,
3250 context: &TopicAdmittanceContext,
3251 bound: Option<&CallBound>,
3252 ) -> TopicValidation {
3253 let tx_record = AppliedTransaction {
3254 txid: txid.to_string(),
3255 topic: topic.to_string(),
3256 };
3257 let is_dupe = match stored(
3267 bound,
3268 self.storage.does_applied_transaction_exist(&tx_record),
3269 )
3270 .await
3271 {
3272 Ok(is_dupe) => is_dupe,
3273 Err(e) => {
3274 error!("Error reading the applied row of {txid} for topic {topic}: {e}");
3275 return TopicValidation {
3276 topic: topic.to_string(),
3277 is_dupe: false,
3278 previous_coins: vec![],
3279 previous_outputs: vec![],
3280 admittance: AdmittanceInstructions::default(),
3281 failed: false,
3282 read_fault: Some(("does_applied_transaction_exist", e.to_string())),
3283 };
3284 }
3285 };
3286
3287 if is_dupe {
3288 return TopicValidation {
3289 topic: topic.to_string(),
3290 is_dupe: true,
3291 previous_coins: vec![],
3292 previous_outputs: vec![],
3293 admittance: AdmittanceInstructions::default(),
3294 failed: false,
3295 read_fault: None,
3296 };
3297 }
3298
3299 let mut previous_coins: Vec<u32> = Vec::new();
3300 let mut previous_outputs: Vec<Output> = Vec::new();
3301 let mut read_fault: Option<(&'static str, String)> = None;
3302
3303 for (input_idx, input) in tx.inputs.iter().enumerate() {
3304 let source_txid = input.get_source_txid().unwrap_or_default();
3305 if source_txid.is_empty() {
3306 continue;
3307 }
3308 match stored(
3309 bound,
3310 self.storage.find_output(
3311 &source_txid,
3312 input.source_output_index,
3313 Some(topic),
3314 None,
3315 false,
3316 ),
3317 )
3318 .await
3319 {
3320 Ok(Some(prev_output)) => {
3321 previous_coins.push(input_idx as u32);
3322 previous_outputs.push(prev_output);
3323 }
3324 Ok(None) => {}
3325 Err(e) => {
3326 error!(
3332 "Error reading previous output {source_txid}:{} for topic {topic}: {e}",
3333 input.source_output_index
3334 );
3335 if read_fault.is_none() {
3336 read_fault = Some(("find_output", e.to_string()));
3337 }
3338 }
3339 }
3340 }
3341
3342 let judged = self
3343 .judge(
3344 tx,
3345 topic,
3346 &previous_coins,
3347 off_chain_values,
3348 mode,
3349 context,
3350 bound,
3351 )
3352 .await;
3353 match judged {
3354 Ok(admittance) => TopicValidation {
3355 topic: topic.to_string(),
3356 is_dupe: false,
3357 previous_coins,
3358 previous_outputs,
3359 admittance,
3360 failed: false,
3361 read_fault: read_fault.clone(),
3362 },
3363 Err(e) => {
3364 error!("Error validating topic {topic} during submit: {e}");
3365 TopicValidation {
3366 topic: topic.to_string(),
3367 is_dupe: false,
3368 previous_coins: vec![],
3369 previous_outputs: vec![],
3370 admittance: AdmittanceInstructions::default(),
3371 failed: true,
3372 read_fault,
3373 }
3374 }
3375 }
3376 }
3377
3378 #[allow(clippy::too_many_arguments)]
3383 async fn judge(
3384 &self,
3385 tx: &Transaction,
3386 topic: &str,
3387 previous_coins: &[u32],
3388 off_chain_values: Option<&[u8]>,
3389 mode: SubmitMode,
3390 context: &TopicAdmittanceContext,
3391 bound: Option<&CallBound>,
3392 ) -> Result<AdmittanceInstructions, TopicManagerError> {
3393 let manager = &self.managers[topic];
3394 let previous_coin_bytes = previous_coins
3395 .iter()
3396 .flat_map(|i| i.to_le_bytes())
3397 .collect::<Vec<u8>>();
3398 bounded(
3401 bound,
3402 manager.identify_admissible_outputs(
3403 tx,
3407 &previous_coin_bytes,
3408 off_chain_values,
3409 mode,
3410 context,
3413 ),
3414 )
3415 .await
3416 .map_err(TopicManagerError::Other)
3417 .and_then(|judged| judged)
3418 }
3419
3420 fn lands_without_values(&self, topic: &str) -> bool {
3424 self.managers
3425 .get(topic)
3426 .is_some_and(|manager| !manager.reads_off_chain_values())
3427 && self
3428 .lookup_services
3429 .values()
3430 .all(|ls| !ls.reads_off_chain_values(topic))
3431 }
3432
3433 async fn run_validation(
3438 &self,
3439 tagged_beef: &TaggedBEEF,
3440 mode: SubmitMode,
3441 context: &TopicAdmittanceContext,
3442 bound: Option<&CallBound>,
3443 walked: Option<&HashSet<String>>,
3444 policy: WalkBreachPolicy,
3445 ) -> Result<Validated, EngineError> {
3446 for topic in &tagged_beef.topics {
3448 if !self.managers.contains_key(topic) {
3449 return Err(EngineError::UnsupportedTopic(topic.clone()));
3450 }
3451 }
3452
3453 let subject = {
3459 let mut b =
3460 beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
3461 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3462 crate::subject::subject_txid_of(&mut b)
3463 };
3464 let tx = beef_limits::transaction_from_beef(
3465 &tagged_beef.beef,
3466 subject.as_deref(),
3467 &beef_limits::ENGINE_BEEF_LIMITS,
3468 )
3469 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3470 let txid = tx.id();
3471
3472 let mut could_not_run = None;
3495 if mode != SubmitMode::HistoricalTxNoSpv {
3496 match walked {
3497 Some(walked) if walked.contains(&txid) => {}
3498 trusted => {
3499 could_not_run = verify_spv_trusting(
3500 self.chain_tracker.as_deref(),
3501 self.verify_scripts,
3502 &tagged_beef.beef,
3503 &txid,
3504 trusted.unwrap_or(&HashSet::new()),
3505 self.walk_budget,
3506 )
3507 .await?;
3508 }
3509 }
3510 }
3511 if let Some(stop) = could_not_run.take() {
3512 match policy {
3513 WalkBreachPolicy::NotNow => {
3514 warn!(
3515 "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): not now, nothing judged or written; present it proven (bsv-low #592, E592 fold)",
3516 stop.subject_txid,
3517 stop.limb.as_str(),
3518 stop.at_txid,
3519 stop.subject_judged,
3520 stop.what
3521 );
3522 return Err(EngineError::WalkCouldNotRun(stop));
3523 }
3524 WalkBreachPolicy::NetworkAccepted => {
3525 warn!(
3526 "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): the network accepted these bytes, the submit goes on as historical-tx-no-spv (bsv-low #592)",
3527 stop.subject_txid,
3528 stop.limb.as_str(),
3529 stop.at_txid,
3530 stop.subject_judged,
3531 stop.what
3532 );
3533 could_not_run = Some(stop);
3534 }
3535 }
3536 }
3537
3538 let mut steak = Steak::new();
3539
3540 let mut validations = Vec::new();
3544
3545 for topic in &tagged_beef.topics {
3546 validations.push(
3547 self.validate_topic(
3548 &tx,
3549 &txid,
3550 topic,
3551 tagged_beef.off_chain_values.as_deref(),
3552 mode,
3553 context,
3554 bound,
3555 )
3556 .await,
3557 );
3558 }
3559
3560 for v in &validations {
3562 steak.insert(v.topic.clone(), v.admittance.clone());
3563 }
3564
3565 if mode == SubmitMode::CurrentTx {
3569 let all_topics_dupe = !validations.is_empty() && validations.iter().all(|v| v.is_dupe);
3584 if let Some(ref arc) = self.arc_broadcaster {
3585 if all_topics_dupe {
3586 info!(
3587 "Skipping ARC broadcast — tx already applied to every topic (re-present)"
3588 );
3589 } else if tx.merkle_path.is_none() {
3590 let raw_tx_hex = tx.to_hex();
3591 match arc.broadcast(&raw_tx_hex).await {
3592 Ok(arc_txid) => {
3593 info!("ARC broadcast succeeded: txid={arc_txid}");
3594 }
3595 Err(e) => {
3596 error!("ARC broadcast failed (non-fatal): {e}");
3597 }
3598 }
3599 } else {
3600 info!("Skipping ARC broadcast — tx already has merkle proof");
3601 }
3602 }
3603
3604 if let Some(ref broadcaster) = self.broadcaster {
3606 let relevant_topics: Vec<String> = validations
3607 .iter()
3608 .filter(|v| {
3609 !v.is_dupe && !v.failed && !v.admittance.outputs_to_admit.is_empty()
3610 })
3611 .map(|v| v.topic.clone())
3612 .collect();
3613
3614 if !relevant_topics.is_empty() {
3615 if let Some(ship_ls) = self.lookup_services.get("ls_ship") {
3616 for topic in &relevant_topics {
3617 let question = LookupQuestion::new(
3618 "ls_ship",
3619 serde_json::json!({ "topics": [topic] }),
3620 );
3621 match ship_ls.lookup(&question).await {
3622 Ok(LookupResult::OutputList(refs)) => {
3623 for reference in &refs {
3624 if let Ok(Some(output)) = self
3625 .storage
3626 .find_output(
3627 &reference.txid,
3628 reference.output_index,
3629 None,
3630 None,
3631 false,
3632 )
3633 .await
3634 {
3635 if let Some(domain) =
3636 parse_ship_domain_from_script(&output.output_script)
3637 {
3638 if let Some(ref our_url) = self.config.hosting_url {
3639 if domain.trim_end_matches('/')
3640 == our_url.trim_end_matches('/')
3641 {
3642 continue;
3643 }
3644 }
3645 if let Err(e) = broadcaster
3646 .broadcast_to_host(&domain, tagged_beef)
3647 .await
3648 {
3649 error!(
3650 "SHIP propagation to {domain} failed for topic {topic}: {e}"
3651 );
3652 }
3653 }
3654 }
3655 }
3656 }
3657 Ok(LookupResult::Answer(_)) => {
3658 error!(
3662 "SHIP lookup for topic {topic} returned a pre-formed \
3663 LookupAnswer; expected OutputList. Skipping fanout."
3664 );
3665 }
3666 Err(e) => {
3667 error!("SHIP lookup for topic {topic} failed: {e}");
3668 }
3669 }
3670 }
3671 }
3672 }
3673 }
3674 }
3675
3676 Ok((validations, steak, tx, txid, could_not_run))
3677 }
3678
3679 pub async fn lookup(
3691 &self,
3692 question: &LookupQuestion,
3693 history_selector: Option<HistorySelector>,
3694 ) -> Result<LookupAnswer, EngineError> {
3695 Ok(self.lookup_with_txids(question, history_selector).await?.0)
3696 }
3697
3698 pub async fn lookup_with_txids(
3707 &self,
3708 question: &LookupQuestion,
3709 history_selector: Option<HistorySelector>,
3710 ) -> Result<(LookupAnswer, Vec<String>), EngineError> {
3711 let service = self
3712 .lookup_services
3713 .get(&question.service)
3714 .ok_or_else(|| EngineError::LookupServiceNotFound(question.service.clone()))?;
3715
3716 let result = service.lookup(question).await.map_err(|e| match e {
3721 LookupServiceError::InvalidQuery(m) | LookupServiceError::Unsupported(m) => {
3722 EngineError::InvalidQuery(m)
3723 }
3724 other => EngineError::LookupFailed(other.to_string()),
3725 })?;
3726
3727 let refs = match result {
3736 LookupResult::OutputList(refs) => refs,
3737 LookupResult::Answer(answer) => return Ok((answer, Vec::new())),
3738 };
3739
3740 let outpoints: Vec<Outpoint> = refs
3748 .iter()
3749 .map(|r| Outpoint::new(&r.txid, r.output_index))
3750 .collect();
3751 let found = self
3752 .storage
3753 .find_served_outputs(&outpoints)
3754 .await
3755 .map_err(|e| EngineError::StorageError(e.to_string()))?;
3756
3757 let mut outputs = Vec::new();
3758 let mut txids = Vec::new();
3759 for output in found {
3760 let final_output = if history_selector.is_some() {
3762 match self
3763 .get_utxo_history(&output, history_selector.clone())
3764 .await
3765 {
3766 Ok(Some(hydrated)) => hydrated,
3767 _ => output,
3768 }
3769 } else {
3770 output
3771 };
3772
3773 if let Some(beef) = final_output.beef {
3780 if !beef.is_empty() {
3781 txids.push(final_output.txid.clone());
3782 outputs.push(OutputListItem {
3783 beef,
3784 output_index: final_output.output_index,
3785 context: None,
3786 });
3787 }
3788 }
3789 }
3790
3791 Ok((LookupAnswer::OutputList { outputs }, txids))
3792 }
3793
3794 pub async fn get_utxo_history(
3808 &self,
3809 output: &Output,
3810 history_selector: Option<HistorySelector>,
3811 ) -> Result<Option<Output>, EngineError> {
3812 let Some(selector) = history_selector else {
3813 return Ok(Some(output.clone()));
3814 };
3815
3816 if output.beef.is_none() {
3818 return Err(EngineError::Other(
3819 "Output must have associated transaction BEEF!".into(),
3820 ));
3821 }
3822
3823 match self.hydrate_utxo_history(output, &selector, 0).await {
3824 Ok(Some(hydrated_output)) => Ok(Some(hydrated_output)),
3825 Ok(None) => Ok(None),
3826 Err(e) => Err(EngineError::Other(format!(
3827 "Error retrieving UTXO history: {e}"
3828 ))),
3829 }
3830 }
3831
3832 fn hydrate_utxo_history<'a>(
3834 &'a self,
3835 output: &'a Output,
3836 selector: &'a HistorySelector,
3837 current_depth: u32,
3838 ) -> std::pin::Pin<
3839 Box<dyn std::future::Future<Output = Result<Option<Output>, EngineError>> + 'a>,
3840 > {
3841 Box::pin(async move {
3842 let beef_data = output.beef.as_ref().ok_or_else(|| {
3843 EngineError::Other("Output must have associated transaction BEEF!".into())
3844 })?;
3845
3846 let should_traverse = match selector {
3848 HistorySelector::Depth(max_depth) => current_depth <= *max_depth,
3849 };
3850
3851 if !should_traverse {
3852 return Ok(None);
3853 }
3854
3855 let mut acc = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3870 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3871
3872 for consumed in &output.outputs_consumed {
3874 if let Ok(Some(child_output)) = self
3875 .storage
3876 .find_output(&consumed.txid, consumed.output_index, None, None, true)
3877 .await
3878 {
3879 if let Ok(Some(hydrated_child)) = self
3880 .hydrate_utxo_history(&child_output, selector, current_depth + 1)
3881 .await
3882 {
3883 if let Some(ref child_beef) = hydrated_child.beef {
3889 if let Ok(child) = Beef::from_binary(child_beef) {
3890 acc.merge_beef(&child);
3891 }
3892 }
3893 }
3894 }
3895 }
3896
3897 Ok(Some(Output {
3898 beef: Some(acc.to_binary()),
3899 ..output.clone()
3900 }))
3901 })
3902 }
3903
3904 pub async fn list_topic_managers(&self) -> HashMap<String, ServiceMetadata> {
3909 let mut result = HashMap::new();
3910 for (name, manager) in &self.managers {
3911 let meta = manager.get_metadata().await;
3912 result.insert(name.clone(), meta);
3913 }
3914 result
3915 }
3916
3917 pub async fn list_lookup_service_providers(&self) -> HashMap<String, ServiceMetadata> {
3919 let mut result = HashMap::new();
3920 for (name, service) in &self.lookup_services {
3921 let meta = service.get_metadata().await;
3922 result.insert(name.clone(), meta);
3923 }
3924 result
3925 }
3926
3927 pub async fn get_documentation_for_topic_manager(&self, name: &str) -> String {
3929 match self.managers.get(name) {
3930 Some(manager) => manager.get_documentation().await,
3931 None => "No documentation found!".to_string(),
3932 }
3933 }
3934
3935 pub async fn get_documentation_for_lookup_service(&self, name: &str) -> String {
3937 match self.lookup_services.get(name) {
3938 Some(service) => service.get_documentation().await,
3939 None => "No documentation found!".to_string(),
3940 }
3941 }
3942
3943 pub async fn provide_foreign_sync_response(
3961 &self,
3962 request: &GASPInitialRequest,
3963 topic: &str,
3964 ) -> Result<GASPInitialResponse, EngineError> {
3965 let limit = Self::clamp_sync_limit(request.limit);
3966 let outputs = self
3967 .storage
3968 .find_utxos_for_topic(topic, Some(request.since as f64), Some(limit), false)
3969 .await
3970 .map_err(|e| EngineError::StorageError(e.to_string()))?;
3971
3972 Ok(GASPInitialResponse {
3973 utxo_list: outputs
3974 .iter()
3975 .map(|o| GASPOutput {
3976 txid: o.txid.clone(),
3977 output_index: o.output_index,
3978 score: o.score.unwrap_or(0.0),
3979 })
3980 .collect(),
3981 since: request.since,
3982 })
3983 }
3984
3985 pub async fn provide_foreign_gasp_node(
3991 &self,
3992 graph_id: &str,
3993 txid: &str,
3994 output_index: u32,
3995 ) -> Result<GASPNode, EngineError> {
3996 let root_outpoint = Outpoint::from_graph_id(graph_id)
3997 .ok_or_else(|| EngineError::Other(format!("Invalid graphID: {graph_id}")))?;
3998
3999 let root_output = self
4000 .storage
4001 .find_output(
4002 &root_outpoint.txid,
4003 root_outpoint.output_index,
4004 None,
4005 None,
4006 true,
4007 )
4008 .await
4009 .map_err(|e| EngineError::StorageError(e.to_string()))?
4010 .ok_or(EngineError::NodeNotFound)?;
4011
4012 self.hydrate_gasp_node(&root_output, graph_id, txid, output_index)
4013 .await
4014 }
4015
4016 fn hydrate_gasp_node<'a>(
4018 &'a self,
4019 output: &'a Output,
4020 graph_id: &'a str,
4021 txid: &'a str,
4022 output_index: u32,
4023 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GASPNode, EngineError>> + 'a>>
4024 {
4025 Box::pin(async move {
4026 let beef_data = output.beef.as_ref().ok_or(EngineError::NodeNotFound)?;
4027
4028 let root = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
4031 .ok()
4032 .and_then(|mut b| crate::subject::subject_txid_of(&mut b));
4033 let root_tx = beef_limits::transaction_from_beef(
4034 beef_data,
4035 root.as_deref(),
4036 &beef_limits::STORED_BEEF_LIMITS,
4037 )
4038 .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
4039
4040 if let Some(node) = Self::search_tx_tree(&root_tx, graph_id, txid, output_index) {
4042 return Ok(node);
4043 }
4044
4045 if let Some(node) = Self::search_beef_for_txid(beef_data, graph_id, txid, output_index)
4048 {
4049 return Ok(node);
4050 }
4051
4052 for consumed in &output.outputs_consumed {
4054 if let Ok(Some(consumed_output)) = self
4055 .storage
4056 .find_output(&consumed.txid, consumed.output_index, None, None, true)
4057 .await
4058 {
4059 if let Ok(node) = self
4060 .hydrate_gasp_node(&consumed_output, graph_id, txid, output_index)
4061 .await
4062 {
4063 return Ok(node);
4064 }
4065 }
4066 }
4067
4068 Err(EngineError::NodeNotFound)
4077 })
4078 }
4079
4080 fn search_tx_tree(
4082 tx: &Transaction,
4083 graph_id: &str,
4084 target_txid: &str,
4085 output_index: u32,
4086 ) -> Option<GASPNode> {
4087 let current_txid = tx.id();
4088
4089 if current_txid == target_txid {
4090 let mut node = GASPNode {
4091 graph_id: graph_id.to_string(),
4092 raw_tx: tx.to_hex(),
4093 output_index,
4094 proof: None,
4095 tx_metadata: None,
4096 output_metadata: None,
4097 inputs: None,
4098 };
4099 if let Some(ref merkle_path) = tx.merkle_path {
4100 node.proof = Some(merkle_path.to_hex());
4101 }
4102 return Some(node);
4103 }
4104
4105 for input in &tx.inputs {
4107 if let Some(ref source_tx) = input.source_transaction {
4108 if let Some(node) =
4109 Self::search_tx_tree(source_tx, graph_id, target_txid, output_index)
4110 {
4111 return Some(node);
4112 }
4113 }
4114 }
4115
4116 None
4117 }
4118
4119 fn search_beef_for_txid(
4123 beef_data: &[u8],
4124 graph_id: &str,
4125 target_txid: &str,
4126 output_index: u32,
4127 ) -> Option<GASPNode> {
4128 if let Ok(tx) = beef_limits::transaction_from_beef(
4130 beef_data,
4131 Some(target_txid),
4132 &beef_limits::STORED_BEEF_LIMITS,
4133 ) {
4134 if tx.id() == target_txid {
4135 let mut node = GASPNode {
4136 graph_id: graph_id.to_string(),
4137 raw_tx: tx.to_hex(),
4138 output_index,
4139 proof: None,
4140 tx_metadata: None,
4141 output_metadata: None,
4142 inputs: None,
4143 };
4144 if let Some(ref merkle_path) = tx.merkle_path {
4145 node.proof = Some(merkle_path.to_hex());
4146 }
4147 return Some(node);
4148 }
4149 }
4150 None
4151 }
4152
4153 pub async fn handle_new_merkle_proof(
4167 &self,
4168 txid: &str,
4169 proof_hex: &str,
4170 block_height: Option<u32>,
4171 ) -> Result<(), EngineError> {
4172 let outputs = self
4173 .storage
4174 .find_outputs_for_transaction(txid, true)
4175 .await
4176 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4177
4178 if outputs.is_empty() {
4179 return Err(EngineError::Other(
4180 "Could not find matching transaction outputs for proof ingest!".into(),
4181 ));
4182 }
4183
4184 let proof = if proof_hex.is_empty() {
4186 None
4187 } else {
4188 Some(
4189 beef_limits::merkle_path_from_hex(proof_hex, beef_limits::PUSH_PROOF_MAX_BYTES)
4190 .map_err(|e| EngineError::Other(format!("Invalid merkle proof hex: {e}")))?,
4191 )
4192 };
4193
4194 for output in &outputs {
4195 if let (Some(ref beef_data), Some(ref proof)) = (&output.beef, &proof) {
4198 if let Some(new_beef) = Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4199 {
4200 let _ = self
4201 .storage
4202 .update_transaction_beef(&output.txid, &new_beef)
4203 .await;
4204 }
4205 }
4206
4207 if let Some(height) = block_height {
4209 let _ = self
4210 .storage
4211 .update_output_block_height(
4212 &output.txid,
4213 output.output_index,
4214 &output.topic,
4215 height,
4216 )
4217 .await;
4218 }
4219
4220 for consuming in &output.consumed_by {
4222 if let Ok(consumed_outputs) = self
4223 .storage
4224 .find_outputs_for_transaction(&consuming.txid, true)
4225 .await
4226 {
4227 for consumed_output in &consumed_outputs {
4228 if let (Some(ref beef_data), Some(ref proof)) =
4234 (&consumed_output.beef, &proof)
4235 {
4236 if let Some(new_beef) =
4237 Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4238 {
4239 let _ = self
4240 .storage
4241 .update_transaction_beef(&consumed_output.txid, &new_beef)
4242 .await;
4243 }
4244 }
4245 }
4246 }
4247 }
4248 }
4249
4250 Ok(())
4251 }
4252
4253 pub async fn complete_missing_proofs(
4289 &self,
4290 limit: u64,
4291 min_age_secs: u64,
4292 ) -> Result<ProofCompletionSummary, EngineError> {
4293 let Some(fetcher) = self.ancestor_fetcher.as_deref() else {
4294 return Ok(ProofCompletionSummary::default());
4295 };
4296 self.complete_missing_proofs_with(fetcher, limit, min_age_secs)
4297 .await
4298 }
4299
4300 pub async fn complete_missing_proofs_with(
4303 &self,
4304 fetcher: &dyn crate::gasp::AncestorFetcher,
4305 limit: u64,
4306 min_age_secs: u64,
4307 ) -> Result<ProofCompletionSummary, EngineError> {
4308 let mut summary = ProofCompletionSummary::default();
4309
4310 let candidates = self
4311 .storage
4312 .find_transactions_for_proof_check(limit, min_age_secs)
4313 .await
4314 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4315 summary.scanned = candidates.len();
4316
4317 for cand in candidates {
4318 let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4322 Ok(b) => b,
4323 Err(e) => {
4324 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4325 continue;
4326 }
4327 };
4328
4329 if let Some(bump_hex) = own_bump
4347 .as_ref()
4348 .map(bsv_rs::transaction::MerklePath::to_hex)
4349 {
4350 if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4351 let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4359 .ok()
4360 .map(|mp| u64::from(mp.block_height));
4361 if let Err(e) = self
4362 .storage
4363 .mark_transaction_proven_at(&cand.txid, height)
4364 .await
4365 {
4366 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4367 } else {
4368 summary.already_proven += 1;
4369 }
4370 continue;
4371 }
4372 warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4373 }
4374 summary.proofless += 1;
4376
4377 let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4382 summary.still_unconfirmed += 1;
4383 continue;
4384 };
4385
4386 let block_height =
4388 beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4389 .ok()
4390 .map(|mp| mp.block_height);
4391
4392 match self
4393 .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4394 .await
4395 {
4396 Ok(()) => summary.completed += 1,
4397 Err(e) => {
4398 warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4399 summary.stitch_failed += 1;
4400 }
4401 }
4402 }
4403
4404 info!(
4405 scanned = summary.scanned,
4406 proofless = summary.proofless,
4407 completed = summary.completed,
4408 still_unconfirmed = summary.still_unconfirmed,
4409 fetch_failed = summary.fetch_failed,
4410 stitch_failed = summary.stitch_failed,
4411 already_proven = summary.already_proven,
4412 "[PROOF COMPLETION] pass complete"
4413 );
4414
4415 Ok(summary)
4416 }
4417
4418 pub fn stitch_proof_into_stored_beef(
4459 stored: &[u8],
4460 txid: &str,
4461 proof: &bsv_rs::transaction::MerklePath,
4462 ) -> Option<Vec<u8>> {
4463 let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4464 beef.find_txid(txid)?;
4465 let i = beef.merge_bump(proof.clone());
4466 let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4470 b.path.first().is_some_and(|leaves| {
4471 leaves
4472 .iter()
4473 .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4474 })
4475 });
4476 if proves_subject {
4477 if let Some(tx) = beef.find_txid_mut(txid) {
4478 tx.set_bump_index(Some(i));
4479 }
4480 }
4481 Self::gc_unreferenced_bumps(&mut beef);
4482 Some(beef.to_binary())
4483 }
4484
4485 fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4491 let referenced: std::collections::HashSet<usize> = beef
4492 .txs
4493 .iter()
4494 .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4495 .collect();
4496 if referenced.len() == beef.bumps.len() {
4497 return;
4498 }
4499 let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4500 let mut kept = Vec::with_capacity(referenced.len());
4501 for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4502 if referenced.contains(&old) {
4503 remap[old] = Some(kept.len());
4504 kept.push(bump);
4505 }
4506 }
4507 beef.bumps = kept;
4508 for tx in &mut beef.txs {
4509 if let Some(old) = tx.bump_index() {
4510 tx.set_bump_index(remap[old]);
4511 }
4512 }
4513 }
4514
4515 pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4529 let mut report = SyncAdvertisementsReport::default();
4530
4531 let Some(advertiser) = &self.advertiser else {
4532 return Ok(report); };
4534
4535 let hosting_url = match &self.config.hosting_url {
4536 Some(url) if !url.is_empty() => url.clone(),
4537 _ => return Ok(report), };
4539
4540 let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4542 let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4543
4544 if self.config.suppress_default_sync_advertisements {
4545 configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4546 configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4547 }
4548
4549 let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4554 Ok(ads) => ads,
4555 Err(e) => {
4556 error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4557 report.lookup_error = Some(format!("ship: {e}"));
4558 return Ok(report);
4559 }
4560 };
4561 let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4562 Ok(ads) => ads,
4563 Err(e) => {
4564 error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4565 report.lookup_error = Some(format!("slap: {e}"));
4566 return Ok(report);
4567 }
4568 };
4569
4570 let ships_to_create: Vec<AdvertisementData> = configured_topics
4572 .iter()
4573 .filter(|topic| {
4574 !current_ship
4575 .iter()
4576 .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4577 })
4578 .map(|topic| AdvertisementData {
4579 protocol: Protocol::Ship,
4580 topic_or_service_name: topic.clone(),
4581 })
4582 .collect();
4583
4584 let slaps_to_create: Vec<AdvertisementData> = configured_services
4585 .iter()
4586 .filter(|service| {
4587 !current_slap
4588 .iter()
4589 .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4590 })
4591 .map(|service| AdvertisementData {
4592 protocol: Protocol::Slap,
4593 topic_or_service_name: service.clone(),
4594 })
4595 .collect();
4596
4597 let ships_to_revoke: Vec<Advertisement> = current_ship
4599 .into_iter()
4600 .filter(|a| !configured_topics.contains(&a.topic_or_service))
4601 .collect();
4602
4603 let slaps_to_revoke: Vec<Advertisement> = current_slap
4604 .into_iter()
4605 .filter(|a| !configured_services.contains(&a.topic_or_service))
4606 .collect();
4607
4608 let mut all_to_create = ships_to_create;
4610 all_to_create.extend(slaps_to_create);
4611 report.to_create = all_to_create.len();
4612 if !all_to_create.is_empty() {
4613 match advertiser.create_advertisements(&all_to_create).await {
4614 Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4615 Ok(steak) => {
4616 for (topic, instructions) in &steak {
4617 report
4618 .admitted
4619 .insert(topic.clone(), instructions.outputs_to_admit.len());
4620 }
4621 }
4622 Err(e) => {
4623 error!("Failed to submit new advertisements: {e}");
4624 report.submit_error = Some(e.to_string());
4625 }
4626 },
4627 Err(e) => {
4628 error!("Failed to create advertisements: {e}");
4629 report.create_error = Some(e.to_string());
4630 }
4631 }
4632 }
4633
4634 let mut all_to_revoke = ships_to_revoke;
4636 all_to_revoke.extend(slaps_to_revoke);
4637 report.to_revoke = all_to_revoke.len();
4638 if !all_to_revoke.is_empty() {
4639 match advertiser.revoke_advertisements(&all_to_revoke).await {
4640 Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4646 report.revoke_skipped = all_to_revoke.len();
4647 }
4648 Ok(tagged_beef) => {
4649 if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4650 error!("Failed to submit revocation: {e}");
4651 report.revoke_submit_error = Some(e.to_string());
4652 }
4653 }
4654 Err(e) => {
4655 error!("Failed to revoke advertisements: {e}");
4656 report.revoke_error = Some(e.to_string());
4657 }
4658 }
4659 }
4660
4661 Ok(report)
4662 }
4663
4664 #[allow(dead_code)] fn delete_utxo_deep<'a>(
4678 &'a self,
4679 output: &'a Output,
4680 bound: Option<&'a CallBound>,
4681 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4682 Box::pin(async move {
4683 if output.consumed_by.is_empty() {
4685 stored(
4686 bound,
4687 self.storage
4688 .delete_output(&output.txid, output.output_index, &output.topic),
4689 )
4690 .await
4691 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4692
4693 for ls in self.lookup_services.values() {
4696 let _ = hooked(
4697 bound,
4698 ls.output_no_longer_retained_in_history(
4699 &output.txid,
4700 output.output_index,
4701 &output.topic,
4702 ),
4703 )
4704 .await;
4705 }
4706 }
4707
4708 for consumed in &output.outputs_consumed {
4710 if let Ok(Some(mut stale_output)) = stored(
4711 bound,
4712 self.storage.find_output(
4713 &consumed.txid,
4714 consumed.output_index,
4715 Some(&output.topic),
4716 None,
4717 false,
4718 ),
4719 )
4720 .await
4721 {
4722 stale_output.consumed_by.retain(|c| {
4724 !(c.txid == output.txid && c.output_index == output.output_index)
4725 });
4726
4727 stored(
4728 bound,
4729 self.storage.update_consumed_by(
4730 &consumed.txid,
4731 consumed.output_index,
4732 &output.topic,
4733 &stale_output.consumed_by,
4734 ),
4735 )
4736 .await
4737 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4738
4739 let _ = self.delete_utxo_deep(&stale_output, bound).await;
4741 }
4742 }
4743
4744 Ok(())
4745 }) }
4747
4748 pub async fn renotify_admitted(
4779 &self,
4780 txid: &str,
4781 topic: &str,
4782 ) -> Result<RenotifyReport, EngineError> {
4783 let txid = txid.to_ascii_lowercase();
4784 let outputs = self
4785 .storage
4786 .find_outputs_for_transaction(&txid, true)
4787 .await
4788 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4789 let mut report = RenotifyReport::default();
4790 for output in outputs.iter().filter(|o| o.topic == topic) {
4791 report.outputs += 1;
4792 report.vouts.push(output.output_index);
4793 let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4796 beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4797 .ok()
4798 .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4799 .unwrap_or_else(|| beef.clone())
4800 });
4801 for (name, ls) in &self.lookup_services {
4802 let payload = match ls.admission_mode() {
4803 AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4804 txid: txid.clone(),
4805 output_index: output.output_index,
4806 topic: topic.to_string(),
4807 satoshis: output.satoshis,
4808 locking_script: output.output_script.clone(),
4809 off_chain_values: None,
4810 },
4811 AdmissionMode::WholeTx => {
4812 let Some(atomic_beef) = subject_named.as_ref() else {
4813 report.faults.push(format!(
4814 "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4815 output.output_index
4816 ));
4817 continue;
4818 };
4819 OutputAdmittedByTopic::WholeTx {
4820 atomic_beef: atomic_beef.clone(),
4821 output_index: output.output_index,
4822 topic: topic.to_string(),
4823 off_chain_values: None,
4824 }
4825 }
4826 };
4827 match ls.output_admitted_by_topic(&payload).await {
4828 Ok(()) => report.notified += 1,
4829 Err(e) => report.faults.push(format!("{name}: {e}")),
4830 }
4831 }
4832 }
4833 Ok(report)
4834 }
4835
4836 pub async fn forget_phantom_applied(
4847 &self,
4848 txid: &str,
4849 topic: &str,
4850 ) -> Result<bool, EngineError> {
4851 let txid = txid.to_ascii_lowercase();
4852 let outputs = self
4853 .storage
4854 .find_outputs_for_transaction(&txid, false)
4855 .await
4856 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4857 if outputs.iter().any(|o| o.topic == topic) {
4858 return Ok(false);
4859 }
4860 let rec = AppliedTransaction {
4861 txid: txid.clone(),
4862 topic: topic.to_string(),
4863 };
4864 let exists = self
4865 .storage
4866 .does_applied_transaction_exist(&rec)
4867 .await
4868 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4869 if !exists {
4870 return Ok(false);
4871 }
4872 self.storage
4873 .delete_applied_transaction(&rec)
4874 .await
4875 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4876 Ok(true)
4877 }
4878
4879 pub async fn evict_output(
4880 &self,
4881 txid: &str,
4882 output_index: u32,
4883 topic: Option<&str>,
4884 ) -> Result<(), EngineError> {
4885 if let Some(topic) = topic {
4886 self.storage
4888 .delete_output(txid, output_index, topic)
4889 .await
4890 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4891
4892 for ls in self.lookup_services.values() {
4894 let _ = ls.output_evicted(txid, output_index).await;
4895 }
4896 } else {
4897 let outputs = self
4899 .storage
4900 .find_outputs_for_transaction(txid, false)
4901 .await
4902 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4903
4904 for output in &outputs {
4905 if output.output_index == output_index {
4906 self.storage
4907 .delete_output(txid, output_index, &output.topic)
4908 .await
4909 .map_err(|e| EngineError::StorageError(e.to_string()))?;
4910 }
4911 }
4912
4913 for ls in self.lookup_services.values() {
4915 let _ = ls.output_evicted(txid, output_index).await;
4916 }
4917 }
4918
4919 Ok(())
4920 }
4921
4922 pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
4947 use crate::gasp::{GASPSync, ENGINE_GASP_SYNC_PAGE_LIMIT};
4948 use crate::gasp_overlay::OverlayGASPStorage;
4949
4950 if self.config.sync_configuration.is_empty() {
4951 info!("[GASP SYNC] No sync configuration — nothing to sync");
4952 return Ok(GASPSyncResult {
4953 topics_synced: HashMap::new(),
4954 });
4955 }
4956
4957 let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
4958 let mut ship_lookup_hosts = None;
4961 let mut topic_order = sync_order(&self.config.sync_configuration);
4962 topic_order.sort_by_key(|(topic, target)| {
4965 !(matches!(target, SyncTarget::Ship)
4966 && self
4967 .ship_bootstrap_peers
4968 .get(topic.as_str())
4969 .is_some_and(|peers| !peers.is_empty()))
4970 });
4971
4972 for (topic, target) in topic_order {
4973 let configured_peers = matches!(target, SyncTarget::Peers(_));
4976 let (peers, sync_type) = match target {
4977 SyncTarget::Disabled => {
4978 info!("[GASP SYNC] Topic {topic} is disabled — skipping");
4979 continue;
4980 }
4981 SyncTarget::Ship => {
4982 info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
4983 let peers = self
4984 .discover_ship_peers(topic, &mut ship_lookup_hosts)
4985 .await;
4986 info!(
4987 "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
4988 peers.len()
4989 );
4990 (peers, "ship".to_string())
4991 }
4992 SyncTarget::Peers(peer_urls) => {
4993 info!(
4994 "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
4995 peer_urls.len()
4996 );
4997 let peers: Vec<String> = peer_urls
4998 .iter()
4999 .filter(|url| {
5000 if let Some(ref our_url) = self.config.hosting_url {
5001 url.trim_end_matches('/') != our_url.trim_end_matches('/')
5002 } else {
5003 true
5004 }
5005 })
5006 .cloned()
5007 .collect();
5008
5009 info!(
5010 "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
5011 peers.len()
5012 );
5013 (peers, "peers".to_string())
5014 }
5015 };
5016
5017 let mut errors = Vec::new();
5018 let mut pruned_inputs: u64 = 0;
5019 let mut discarded_graphs: u64 = 0;
5020 let mut expired_refused: u64 = 0;
5021 let mut roots_proven = 0;
5022 let mut roots_unproven = 0;
5023 let mut cursor_held_by = Vec::new();
5024 let expired = std::rc::Rc::default();
5025 let mut anchor_walk_held_graphs: u64 = 0;
5026 let mut finalized_graphs: u64 = 0;
5027 let mut deadline_dropped_graphs: u64 = 0;
5028 let mut cursor_moves: Vec<CursorMove> = Vec::new();
5029 let mut deferred_graphs: u64 = 0;
5030 let mut resumed_graphs: u64 = 0;
5031 let mut converged_graphs: u64 = 0;
5032 let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
5033 let mut stalled_graphs: u64 = 0;
5034 let mut held_back_graphs: u64 = 0;
5035 let mut unanchorable_graphs: u64 = 0;
5036 let mut unanchorable_cleared: u64 = 0;
5037 let mut faulted_roots: Vec<crate::gasp::FaultedRoot> = Vec::new();
5038
5039 if let Some(ref factory) = self.gasp_remote_factory {
5041 for peer_url in &peers {
5042 let health_key = crate::gasp::peer_origin(peer_url);
5055 let health = self
5056 .storage
5057 .get_peer_sync_health(&health_key, topic)
5058 .await
5059 .unwrap_or_default();
5060 if crate::gasp::peer_sync_quarantined(&health) {
5061 warn!(
5062 "[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)",
5063 health.consecutive_failures,
5064 health.secs_since_last_attempt,
5065 crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5066 );
5067 continue;
5068 }
5069
5070 info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
5071
5072 let last_interaction = self
5074 .storage
5075 .get_last_interaction(peer_url, topic)
5076 .await
5077 .unwrap_or(0);
5078
5079 let sink = crate::gasp_overlay::new_finalized_graph_sink();
5081
5082 let sync_fetcher = self
5091 .gasp_ancestor_fetcher
5092 .as_ref()
5093 .or(self.ancestor_fetcher.as_ref());
5094 let hydration_on = sync_fetcher.is_some();
5095
5096 let mut gasp_storage =
5098 OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5099 .with_peer(peer_url.as_str())
5100 .with_configured_peer(configured_peers)
5101 .with_strict_beef(hydration_on)
5102 .with_script_verification(self.verify_scripts)
5103 .with_walk_budget(self.walk_budget);
5104 if let Some(manager) = self.managers.get(topic) {
5105 gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5106 }
5107 if let Some(tracker) = self.chain_tracker.as_deref() {
5112 gasp_storage = gasp_storage.with_chain_tracker(tracker);
5113 }
5114 let gasp_remote = factory.create_remote(peer_url, topic);
5115
5116 let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5117 let peer_finalized = std::cell::Cell::new(0u64);
5119 let mut sync = GASPSync::new(
5120 Box::new(gasp_storage),
5121 gasp_remote,
5122 last_interaction,
5123 &log_prefix,
5124 true, )
5126 .with_ancestor_fetcher(sync_fetcher.cloned())
5127 .with_expired_outpoints(std::rc::Rc::clone(&expired));
5128 if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5131 let (sleep, budget_ms) = (sleep.clone(), *budget_ms);
5132 let half_sleep = sleep.clone();
5133 sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5134 max_calls: *max_calls,
5135 max_bytes_fetched: self.graph_budget_limbs.0,
5136 max_nodes: self.graph_budget_limbs.1,
5137 deadline: Box::new(move || sleep(budget_ms)),
5138 half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5139 });
5140 }
5141 if self.peer_sync_budget.is_some() {
5146 sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5147 engine: self,
5148 sink: sink.clone(),
5149 peer_url,
5150 topic,
5151 submitted: &peer_finalized,
5152 }));
5153 }
5154
5155 let sync_outcome = match &self.peer_sync_budget {
5165 Some((sleep, budget_ms)) => {
5166 crate::gasp::race_or_deadline_guarded(
5167 sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5168 sleep(*budget_ms),
5169 &self.finalize_gate,
5170 )
5171 .await
5172 }
5173 None => Some(sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)).await),
5174 };
5175
5176 let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5177 pruned_inputs += sync.pruned_inputs();
5183 expired_refused += sync.expired_refused();
5184 roots_proven += sync.roots_proven();
5185 roots_unproven += sync.roots_unproven();
5186 discarded_graphs += sync.discarded_graphs();
5191 anchor_walk_held_graphs += sync.anchor_walk_held();
5192 let ran = !matches!(sync_outcome, Some(Err(_)));
5196 let mut advanced_cursor = false;
5197 match sync_outcome {
5198 None => {
5199 let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5200 let msg = format!(
5201 "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5202 );
5203 warn!("[GASP SYNC] {msg}");
5204 errors.push(msg);
5205
5206 let in_flight = u64::from(sync.graph_in_flight());
5217 deadline_dropped_graphs += in_flight;
5218 sync.defer_in_flight().await;
5221 let completed = sync.completed_cursor();
5222 if completed > last_interaction {
5223 match self
5224 .storage
5225 .update_last_interaction(peer_url, topic, completed)
5226 .await
5227 {
5228 Ok(()) => cursor_moves.push(CursorMove {
5229 peer: peer_url.clone(),
5230 from: last_interaction,
5231 to: completed,
5232 }),
5233 Err(e) => warn!(
5234 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5235 ),
5236 }
5237 }
5238 advanced_cursor = completed > last_interaction;
5258 outcome_success = peer_finalized.get() > 0
5259 || (completed > last_interaction && sync.cursor_yielded())
5260 || sync.deferral_stats().progressed > 0
5261 || sync.did_work();
5262 warn!(
5263 "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5264 peer_finalized.get(),
5265 completed.max(last_interaction)
5266 );
5267 }
5268 Some(Ok(())) => {
5269 let landed = self
5273 .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5274 .await;
5275 let stats = sync.deferral_stats();
5286 if stats.stalled > 0
5287 && stats.progressed == 0
5288 && peer_finalized.get() == 0
5289 && !sync.did_work()
5290 && !(landed
5291 && sync.last_interaction > last_interaction
5292 && sync.cursor_yielded())
5293 {
5294 outcome_success = false;
5295 let msg = format!(
5296 "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5297 stats.stalled
5298 );
5299 warn!("[GASP SYNC] {msg}");
5300 errors.push(msg);
5301 }
5302
5303 advanced_cursor = landed && sync.last_interaction > last_interaction;
5322 if !landed {
5323 warn!(
5324 "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5325 );
5326 } else if sync.last_interaction > last_interaction {
5327 match self
5328 .storage
5329 .update_last_interaction(peer_url, topic, sync.last_interaction)
5330 .await
5331 {
5332 Ok(()) => cursor_moves.push(CursorMove {
5333 peer: peer_url.clone(),
5334 from: last_interaction,
5335 to: sync.last_interaction,
5336 }),
5337 Err(e) => warn!(
5338 "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5339 ),
5340 }
5341 }
5342 info!(
5343 "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5344 sync.last_interaction
5345 );
5346 }
5347 Some(Err(e)) => {
5348 let msg = format!("{peer_url}: {e}");
5349 warn!("[GASP SYNC] Sync failed: {msg}");
5350 errors.push(msg);
5351 }
5352 }
5353 let yielded = peer_finalized.get() > 0
5370 || (advanced_cursor && sync.cursor_yielded())
5371 || sync.expired_refused() > 0;
5372 if yielded || sync.did_work() {
5373 outcome_success = true;
5374 }
5375 if ran
5386 && self.graph_budget.is_some()
5387 && (yielded || sync.graphs_attempted() > 0)
5388 {
5389 match self
5390 .storage
5391 .record_peer_sync_yield(&health_key, topic, yielded)
5392 .await
5393 {
5394 Ok(streak)
5395 if !yielded
5396 && crate::gasp::yieldless_sync_failed(&streak)
5397 && outcome_success =>
5398 {
5399 outcome_success = false;
5400 let msg = format!(
5401 "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5402 streak.yieldless_syncs,
5403 streak.secs_since_first.unwrap_or(0),
5404 crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5405 crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5406 );
5407 warn!("[GASP SYNC] {msg}");
5408 errors.push(msg);
5409 }
5410 Ok(_) => {}
5411 Err(e) => warn!(
5412 "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5413 ),
5414 }
5415 }
5416 if !advanced_cursor {
5417 if let Some(outpoint) = sync.cursor_held_by() {
5418 cursor_held_by.push(outpoint);
5419 }
5420 }
5421 finalized_graphs += peer_finalized.get();
5422 let deferral = sync.deferral_stats();
5423 deferred_graphs += deferral.deferred;
5424 resumed_graphs += deferral.resumed;
5425 converged_graphs += deferral.converged;
5426 stalled_graphs += deferral.stalled;
5427 held_back_graphs += deferral.held_back;
5428 dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5429 peer: peer_url.clone(),
5430 outpoint: d.outpoint,
5431 reason: d.reason.as_str().to_string(),
5432 }));
5433 unanchorable_graphs += sync.unanchorable_recorded();
5435 unanchorable_cleared += sync.unanchorable_cleared();
5436 faulted_roots.extend(sync.faulted_roots().into_iter().map(|mut f| {
5437 f.peer.clone_from(peer_url);
5438 f
5439 }));
5440
5441 if outcome_success
5461 && !yielded
5462 && self.graph_budget.is_some()
5463 && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5464 {
5465 info!(
5466 "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5467 health.consecutive_failures
5468 );
5469 } else if let Err(e) = self
5470 .storage
5471 .record_peer_sync_outcome(&health_key, topic, outcome_success)
5472 .await
5473 {
5474 warn!(
5475 "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5476 );
5477 }
5478 }
5479 }
5480
5481 topics_synced.insert(
5482 topic.clone(),
5483 TopicSyncResult {
5484 roots_proven,
5485 roots_unproven,
5486 cursor_held_by,
5487 expired_refused,
5488 peers,
5489 sync_type,
5490 errors,
5491 pruned_inputs,
5492 discarded_graphs,
5493 anchor_walk_held_graphs,
5494 finalized_graphs,
5495 deadline_dropped_graphs,
5496 cursor_moves,
5497 deferred_graphs,
5498 resumed_graphs,
5499 converged_graphs,
5500 dropped_graphs,
5501 stalled_graphs,
5502 held_back_graphs,
5503 unanchorable_graphs,
5504 unanchorable_cleared,
5505 faulted_roots,
5506 },
5507 );
5508 }
5509
5510 info!(
5511 "[GASP SYNC] Peer discovery complete for {} topic(s)",
5512 topics_synced.len()
5513 );
5514
5515 let expired: u64 = topics_synced.values().map(|r| r.expired_refused).sum();
5516 if expired > 0 {
5517 info!("[GASP SYNC] gaspExpiredRefused={expired}");
5518 }
5519 let unanchorable: u64 = topics_synced.values().map(|r| r.unanchorable_graphs).sum();
5520 if unanchorable > 0 {
5521 info!("[GASP SYNC] gaspUnanchorable={unanchorable}");
5522 }
5523 Ok(GASPSyncResult { topics_synced })
5524 }
5525
5526 async fn submit_finalized_graphs(
5550 &self,
5551 sink: &crate::gasp_overlay::FinalizedGraphSink,
5552 peer_url: &str,
5553 topic: &str,
5554 submitted: &std::cell::Cell<u64>,
5555 ) -> bool {
5556 let finalized: Vec<_> = sink
5557 .lock()
5558 .unwrap_or_else(std::sync::PoisonError::into_inner)
5559 .drain(..)
5560 .collect();
5561
5562 let mut all_landed = true;
5563 for graph in &finalized {
5564 for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5565 self.finalize_gate.stop_if_due().await;
5566 if position == 0 {
5567 submitted.set(submitted.get() + 1);
5568 }
5569 let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5570 let outcome = {
5580 let _writing = self.finalize_gate.write_section();
5581 let bound = self
5582 .finalize_submit_budget
5583 .as_ref()
5584 .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5585 self.submit_bounded(
5586 &tagged,
5587 SubmitMode::HistoricalTxNoSpv,
5588 bound.as_ref(),
5589 true,
5590 WalkBreachPolicy::NotNow,
5591 )
5592 .await
5593 };
5594 let not_landed = match outcome {
5595 Ok((_, report)) => {
5596 let landed = report.is_durable()
5597 && (report.applied_topics.contains(&graph.topic)
5598 || report.deduped_topics.contains(&graph.topic));
5599 (!landed).then(|| {
5600 if report.is_durable() {
5601 "the topic manager failed on it".to_string()
5602 } else {
5603 format!("not durable: {}", report.summary())
5604 }
5605 })
5606 }
5607 Err(e) => Some(e.to_string()),
5608 };
5609 if let Some(why) = not_landed {
5610 warn!(
5611 "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
5612 graph.topic
5613 );
5614 all_landed = false;
5615 break;
5616 }
5617 }
5618 }
5619
5620 if !finalized.is_empty() {
5621 info!(
5622 "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
5623 finalized.len()
5624 );
5625 }
5626 all_landed
5627 }
5628
5629 pub async fn rerequest_held_proofs(
5635 &self,
5636 candidates: &[crate::proof_window::HeldOutput],
5637 progress: &std::cell::Cell<crate::proof_window::PeerProofSummary>,
5638 ) {
5639 use crate::proof_window::{PEER_PROOF_REQUEST_MS, PEER_PROOF_ROW_LIMIT};
5640 let Some(factory) = &self.gasp_remote_factory else {
5641 return;
5642 };
5643 let Some(tracker) = self.chain_tracker.as_deref() else {
5644 return;
5645 };
5646 let mut lookup_hosts = None;
5647 let mut topic_peers = HashMap::new();
5648 let mut landed = HashSet::new();
5649 for candidate in candidates.iter().take(PEER_PROOF_ROW_LIMIT) {
5650 if !self.managers.contains_key(&candidate.topic) {
5651 continue;
5652 }
5653 if let std::collections::hash_map::Entry::Vacant(entry) =
5654 topic_peers.entry(candidate.topic.clone())
5655 {
5656 let peers = match self.config.sync_configuration.get(&candidate.topic) {
5657 Some(SyncTarget::Ship) => {
5658 self.discover_ship_peers(&candidate.topic, &mut lookup_hosts)
5659 .await
5660 }
5661 Some(SyncTarget::Peers(peers)) => self.ship_peers_excluding_self(peers.clone()),
5662 _ => Vec::new(),
5663 };
5664 entry.insert(peers);
5665 }
5666 for peer in &topic_peers[&candidate.topic] {
5667 let remote = factory.create_remote(peer, &candidate.topic);
5668 let graph_id = format!("{}.{}", candidate.txid, candidate.output_index);
5669 let mut summary = progress.get();
5670 summary.requests += 1;
5671 progress.set(summary);
5672 let ask =
5673 remote.request_node(&graph_id, &candidate.txid, candidate.output_index, false);
5674 let response = if let Some((sleep, _)) = &self.peer_sync_budget {
5675 crate::gasp::race_or_deadline(ask, sleep(PEER_PROOF_REQUEST_MS)).await
5676 } else {
5677 Some(ask.await)
5678 };
5679 let Some(Ok(node)) = response else {
5680 let mut summary = progress.get();
5681 summary.faults += 1;
5682 progress.set(summary);
5683 continue;
5684 };
5685 if landed.contains(&candidate.txid) {
5686 continue;
5687 }
5688 let Some(proof_hex) = node.proof else {
5689 continue;
5690 };
5691 let Ok(mut proof) = beef_limits::merkle_path_from_hex(
5692 &proof_hex,
5693 beef_limits::COURIER_PROOF_MAX_BYTES,
5694 ) else {
5695 let mut summary = progress.get();
5696 summary.refused += 1;
5697 progress.set(summary);
5698 continue;
5699 };
5700 let valid = match proof.compute_root(Some(&candidate.txid)) {
5701 Ok(root) => tracker
5702 .is_valid_root_for_height(&root, proof.block_height)
5703 .await
5704 .unwrap_or(false),
5705 Err(_) => false,
5706 };
5707 if !valid {
5708 let mut summary = progress.get();
5709 summary.refused += 1;
5710 progress.set(summary);
5711 continue;
5712 }
5713 for leaf in &mut proof.path[0] {
5716 if leaf.hash.as_deref() == Some(&candidate.txid) {
5717 leaf.txid = true;
5718 }
5719 }
5720 match self.land_peer_proof(candidate, &proof).await {
5721 Ok(()) => {
5722 landed.insert(candidate.txid.clone());
5723 let mut summary = progress.get();
5724 summary.landed += 1;
5725 progress.set(summary);
5726 }
5727 Err(error) => {
5728 warn!(%error, "[proof window] verified proof could not be stored");
5729 let mut summary = progress.get();
5730 summary.faults += 1;
5731 progress.set(summary);
5732 }
5733 }
5734 }
5735 }
5736 }
5737
5738 async fn land_peer_proof(
5739 &self,
5740 candidate: &crate::proof_window::HeldOutput,
5741 proof: &bsv_rs::transaction::MerklePath,
5742 ) -> Result<(), EngineError> {
5743 let output = self
5744 .storage
5745 .find_output(
5746 &candidate.txid,
5747 candidate.output_index,
5748 Some(&candidate.topic),
5749 None,
5750 true,
5751 )
5752 .await
5753 .map_err(|e| EngineError::StorageError(e.to_string()))?
5754 .ok_or(EngineError::NodeNotFound)?;
5755 let beef = output
5756 .beef
5757 .ok_or_else(|| EngineError::BeefParseError("missing stored BEEF".into()))?;
5758 let stitched = Self::stitch_proof_into_stored_beef(&beef, &candidate.txid, proof)
5759 .ok_or_else(|| {
5760 EngineError::BeefParseError("proof does not stitch into stored subject".into())
5761 })?;
5762 self.storage
5763 .update_transaction_beef(&candidate.txid, &stitched)
5764 .await
5765 .map_err(|e| EngineError::StorageError(e.to_string()))?;
5766 self.storage
5767 .mark_transaction_proven_at(&candidate.txid, Some(u64::from(proof.block_height)))
5768 .await
5769 .map_err(|e| EngineError::StorageError(e.to_string()))
5770 }
5771
5772 async fn discover_ship_peers(
5773 &self,
5774 topic: &str,
5775 lookup_hosts: &mut Option<Vec<String>>,
5776 ) -> Vec<String> {
5777 let mut domains = self.local_ship_peers(topic).await;
5778 if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
5779 domains.extend(bootstrap.iter().cloned());
5780 }
5781 if let Some(client) = &self.ship_lookup_client {
5782 if lookup_hosts.is_none() {
5783 *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
5784 }
5785 let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
5786 for host in lookup_hosts.as_ref().into_iter().flatten() {
5787 match client.lookup(host, &question).await {
5788 Ok(outputs) => {
5789 domains.extend(outputs.iter().filter_map(|output| {
5790 parse_lookup_advertisement(output, b"SHIP", topic)
5791 }));
5792 }
5793 Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
5794 }
5795 }
5796 }
5797 self.ship_peers_excluding_self(domains)
5798 }
5799
5800 async fn discover_ship_lookup_hosts(
5801 &self,
5802 client: &dyn crate::ship_lookup::ShipLookupClient,
5803 ) -> Vec<String> {
5804 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
5805 let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
5806 let tracker_origins: HashSet<_> = trackers
5807 .iter()
5808 .map(|host| crate::gasp::peer_origin(host))
5809 .collect();
5810 let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
5811 let mut advertised = Vec::new();
5812 for tracker in &trackers {
5813 match client.lookup(tracker, &question).await {
5814 Ok(outputs) => {
5815 advertised.extend(outputs.iter().filter_map(|output| {
5816 parse_lookup_advertisement(output, b"SLAP", "ls_ship")
5817 }));
5818 }
5819 Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
5820 }
5821 }
5822 let providers = self
5823 .ship_peers_excluding_self(advertised)
5824 .into_iter()
5825 .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
5826 .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
5827 trackers.into_iter().chain(providers).collect()
5828 }
5829
5830 fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
5831 crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
5832 self.config.hosting_url.as_ref().is_none_or(|our_url| {
5833 crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
5834 })
5835 }))
5836 }
5837
5838 async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
5840 let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
5841 warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
5842 return Vec::new();
5843 };
5844
5845 let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
5846
5847 let refs = match ship_ls.lookup(&question).await {
5848 Ok(LookupResult::OutputList(refs)) => refs,
5849 Ok(LookupResult::Answer(_)) => {
5850 error!(
5851 "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
5852 LookupAnswer; expected OutputList. Skipping."
5853 );
5854 return Vec::new();
5855 }
5856 Err(e) => {
5857 error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
5858 return Vec::new();
5859 }
5860 };
5861
5862 let mut domains = HashSet::new();
5863 for reference in &refs {
5864 if let Ok(Some(output)) = self
5865 .storage
5866 .find_output(&reference.txid, reference.output_index, None, None, false)
5867 .await
5868 {
5869 if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
5870 if let Some(ref our_url) = self.config.hosting_url {
5874 if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
5875 || crate::gasp::peer_origin(&domain)
5876 == crate::gasp::peer_origin(our_url)
5877 {
5878 continue;
5879 }
5880 }
5881 domains.insert(domain);
5882 }
5883 }
5884 }
5885
5886 crate::gasp::ship_peers_by_origin(domains)
5890 }
5891
5892 pub fn storage(&self) -> &dyn Storage {
5898 self.storage.as_ref()
5899 }
5900
5901 pub fn config(&self) -> &EngineConfig {
5903 &self.config
5904 }
5905}
5906
5907pub(crate) async fn verify_spv_like_the_reference(
5924 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5925 verify_scripts: bool,
5926 beef_bytes: &[u8],
5927 subject_txid: &str,
5928 budget: DoorBudget,
5929) -> Result<Option<WalkCouldNotRun>, EngineError> {
5930 verify_spv_trusting(
5931 chain_tracker,
5932 verify_scripts,
5933 beef_bytes,
5934 subject_txid,
5935 &HashSet::new(),
5936 budget,
5937 )
5938 .await
5939}
5940
5941async fn verify_spv_trusting(
5946 chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
5947 verify_scripts: bool,
5948 beef_bytes: &[u8],
5949 subject_txid: &str,
5950 trusted: &HashSet<String>,
5951 budget: DoorBudget,
5952) -> Result<Option<WalkCouldNotRun>, EngineError> {
5953 if verify_scripts {
5954 Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
5955 } else {
5956 Engine::verify_spv_structurally(chain_tracker, beef_bytes)
5957 .await
5958 .map(|()| None)
5959 }
5960}
5961
5962fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
5966 let mut proven: HashMap<&str, usize> = HashMap::new();
5967 for (at, bump) in beef.bumps.iter().enumerate() {
5968 for leaf in bump.path.first().into_iter().flatten() {
5969 if let Some(hash) = leaf.hash.as_deref() {
5970 proven.entry(hash).or_insert(at);
5971 }
5972 }
5973 }
5974 proven
5975}
5976
5977fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
5982 let bodies: HashMap<String, &Transaction> = beef
5983 .txs
5984 .iter()
5985 .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
5986 .collect();
5987 let proven = proven_index(beef);
5988 let mut cover = HashSet::new();
5989 let mut queue = vec![from.to_string()];
5990 while let Some(txid) = queue.pop() {
5991 if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
5992 continue;
5993 }
5994 if let Some(tx) = bodies.get(&txid) {
5995 queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
5996 }
5997 }
5998 cover
5999}
6000
6001struct SubmitAsFinalized<'e> {
6004 engine: &'e Engine,
6005 sink: crate::gasp_overlay::FinalizedGraphSink,
6006 peer_url: &'e str,
6007 topic: &'e str,
6008 submitted: &'e std::cell::Cell<u64>,
6009}
6010
6011#[async_trait::async_trait(?Send)]
6012impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
6013 async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
6014 let landed = self
6018 .engine
6019 .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
6020 .await;
6021 if landed {
6022 Ok(())
6023 } else {
6024 Err(crate::gasp::GASPError::StorageError(
6027 "a finalize submit did not land".to_string(),
6028 ))
6029 }
6030 }
6031}
6032
6033#[derive(Debug, Clone, Serialize, Deserialize)]
6037pub struct GASPSyncResult {
6038 pub topics_synced: HashMap<String, TopicSyncResult>,
6040}
6041
6042#[derive(Debug, Clone, Serialize, Deserialize)]
6044pub struct TopicSyncResult {
6045 #[serde(default)]
6047 pub roots_proven: u64,
6048 #[serde(default)]
6049 pub roots_unproven: u64,
6050 #[serde(default)]
6052 pub cursor_held_by: Vec<String>,
6053 #[serde(default)]
6055 pub expired_refused: u64,
6056 pub peers: Vec<String>,
6058 pub sync_type: String,
6060 pub errors: Vec<String>,
6066 #[serde(default)]
6072 pub pruned_inputs: u64,
6073 #[serde(default)]
6078 pub discarded_graphs: u64,
6079 #[serde(default)]
6085 pub anchor_walk_held_graphs: u64,
6086 #[serde(default)]
6096 pub finalized_graphs: u64,
6097 #[serde(default)]
6105 pub deadline_dropped_graphs: u64,
6106 #[serde(default)]
6111 pub cursor_moves: Vec<CursorMove>,
6112 #[serde(default)]
6116 pub deferred_graphs: u64,
6117 #[serde(default)]
6119 pub resumed_graphs: u64,
6120 #[serde(default)]
6123 pub converged_graphs: u64,
6124 #[serde(default)]
6126 pub dropped_graphs: Vec<DroppedDeferral>,
6127 #[serde(default)]
6132 pub stalled_graphs: u64,
6133 #[serde(default)]
6137 pub held_back_graphs: u64,
6138 #[serde(default)]
6144 pub unanchorable_graphs: u64,
6145 #[serde(default)]
6148 pub unanchorable_cleared: u64,
6149 #[serde(default)]
6155 pub faulted_roots: Vec<crate::gasp::FaultedRoot>,
6156}
6157
6158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6161pub struct DroppedDeferral {
6162 pub peer: String,
6164 pub outpoint: String,
6166 pub reason: String,
6168}
6169
6170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6173pub struct CursorMove {
6174 pub peer: String,
6176 pub from: u64,
6178 pub to: u64,
6180}
6181
6182pub(crate) fn current_timestamp_ms() -> f64 {
6187 #[cfg(target_arch = "wasm32")]
6188 {
6189 js_sys::Date::now()
6190 }
6191 #[cfg(not(target_arch = "wasm32"))]
6192 {
6193 std::time::SystemTime::now()
6194 .duration_since(std::time::UNIX_EPOCH)
6195 .map_or(0.0, |d| d.as_millis() as f64)
6196 }
6197}
6198
6199fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
6205 parse_advertisement_domain_from_script(output_script, b"SHIP", None)
6206}
6207
6208fn parse_lookup_advertisement(
6209 output: &OutputListItem,
6210 protocol: &[u8],
6211 scope: &str,
6212) -> Option<String> {
6213 let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
6214 .ok()?;
6215 let script = tx
6216 .outputs
6217 .get(output.output_index as usize)?
6218 .locking_script
6219 .to_binary();
6220 parse_advertisement_domain_from_script(&script, protocol, Some(scope))
6221}
6222
6223fn parse_advertisement_domain_from_script(
6224 output_script: &[u8],
6225 protocol: &[u8],
6226 scope: Option<&str>,
6227) -> Option<String> {
6228 use bsv_rs::script::templates::PushDrop;
6229
6230 let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
6231 let pushdrop = PushDrop::decode(&script.into()).ok()?;
6232
6233 if pushdrop.fields.len() < 3 {
6234 return None;
6235 }
6236
6237 if pushdrop.fields[0] != protocol {
6238 return None;
6239 }
6240
6241 if scope.is_some_and(|expected| {
6242 pushdrop
6243 .fields
6244 .get(3)
6245 .is_none_or(|actual| actual != expected.as_bytes())
6246 }) {
6247 return None;
6248 }
6249
6250 let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
6251 if domain.is_empty() {
6252 return None;
6253 }
6254
6255 Some(domain)
6256}
6257
6258#[derive(Debug, thiserror::Error)]
6267pub enum EngineError {
6268 #[error("This server does not support this topic: {0}")]
6269 UnsupportedTopic(String),
6270
6271 #[error("Lookup service not found for provider: {0}")]
6272 LookupServiceNotFound(String),
6273
6274 #[error("No matching output found!")]
6277 NodeNotFound,
6278
6279 #[error("lookup failed: {0}")]
6280 LookupFailed(String),
6281
6282 #[error("invalid query: {0}")]
6289 InvalidQuery(String),
6290
6291 #[error("storage error: {0}")]
6292 StorageError(String),
6293
6294 #[error("broadcast error: {0}")]
6295 BroadcastError(String),
6296
6297 #[error("SPV verification failed: {0}")]
6298 SpvError(String),
6299
6300 #[error("BEEF parsing failed: {0}")]
6301 BeefParseError(String),
6302
6303 #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6313 ScriptVerificationFailed {
6314 subject_txid: String,
6315 input_index: u32,
6316 reason: String,
6317 },
6318
6319 #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6326 ScriptWalkInconclusive {
6327 at_txid: String,
6328 subject_judged: bool,
6329 reason: String,
6330 },
6331
6332 #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6337 ScriptWalkOverBudget {
6338 at_txid: String,
6339 subject_judged: bool,
6340 limb: DoorLimb,
6341 what: String,
6342 },
6343
6344 #[error(
6354 "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6355 .0.limb.as_str(),
6356 .0.what
6357 )]
6358 WalkCouldNotRun(WalkCouldNotRun),
6359
6360 #[error("{0}")]
6361 Other(String),
6362}
6363
6364impl From<StorageError> for EngineError {
6365 fn from(e: StorageError) -> Self {
6366 EngineError::StorageError(e.to_string())
6367 }
6368}
6369
6370impl From<TopicManagerError> for EngineError {
6371 fn from(e: TopicManagerError) -> Self {
6372 EngineError::Other(e.to_string())
6373 }
6374}
6375
6376impl From<LookupServiceError> for EngineError {
6377 fn from(e: LookupServiceError) -> Self {
6378 EngineError::LookupFailed(e.to_string())
6379 }
6380}
6381
6382impl From<AdvertiserError> for EngineError {
6383 fn from(e: AdvertiserError) -> Self {
6384 EngineError::Other(e.to_string())
6385 }
6386}
6387
6388pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6398 let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6399 order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6400 order
6401}
6402
6403#[cfg(test)]
6404mod tests {
6405 use super::*;
6406 use crate::lookup_service::LookupService as LookupServiceTrait;
6407 use crate::storage::memory::MemoryStorage;
6408 use crate::topic_manager::TopicManager as TopicManagerTrait;
6409 use async_trait::async_trait;
6410 use std::sync::Mutex;
6411
6412 #[test]
6416 fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6417 let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6418 let config: SyncConfiguration = HashMap::from([
6419 ("tm_a".to_string(), SyncTarget::Ship),
6420 ("tm_z".to_string(), peers("https://z")),
6421 ("tm_b".to_string(), SyncTarget::Ship),
6422 ("tm_m".to_string(), peers("https://m")),
6423 ("tm_off".to_string(), SyncTarget::Disabled),
6424 ]);
6425 let order: Vec<&str> = sync_order(&config)
6426 .iter()
6427 .map(|(t, _)| t.as_str())
6428 .collect();
6429 assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6430 }
6431
6432 struct MockTopicManager {
6435 admit_indices: Vec<u32>,
6436 }
6437
6438 impl MockTopicManager {
6439 fn admitting(indices: Vec<u32>) -> Self {
6440 Self {
6441 admit_indices: indices,
6442 }
6443 }
6444 }
6445
6446 #[async_trait(?Send)]
6447 impl TopicManagerTrait for MockTopicManager {
6448 async fn identify_admissible_outputs(
6449 &self,
6450 _tx: &Transaction,
6451 _previous_coins: &[u8],
6452 _off_chain_values: Option<&[u8]>,
6453 _mode: SubmitMode,
6454 _context: &TopicAdmittanceContext,
6455 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6456 Ok(AdmittanceInstructions {
6457 outputs_to_admit: self.admit_indices.clone(),
6458 coins_to_retain: vec![],
6459 coins_removed: None,
6460 })
6461 }
6462
6463 async fn get_documentation(&self) -> String {
6464 "Mock topic manager".to_string()
6465 }
6466
6467 async fn get_metadata(&self) -> ServiceMetadata {
6468 ServiceMetadata {
6469 name: "mock-tm".to_string(),
6470 description: Some("Mock for testing".to_string()),
6471 ..Default::default()
6472 }
6473 }
6474 }
6475
6476 struct MockLookupService {
6479 records: Mutex<Vec<UTXOReference>>,
6480 }
6481
6482 impl MockLookupService {
6483 fn new() -> Self {
6484 Self {
6485 records: Mutex::new(Vec::new()),
6486 }
6487 }
6488 }
6489
6490 #[async_trait(?Send)]
6491 impl LookupServiceTrait for MockLookupService {
6492 fn admission_mode(&self) -> AdmissionMode {
6493 AdmissionMode::LockingScript
6494 }
6495
6496 fn spend_notification_mode(&self) -> SpendNotificationMode {
6497 SpendNotificationMode::None
6498 }
6499
6500 async fn output_admitted_by_topic(
6501 &self,
6502 payload: &OutputAdmittedByTopic,
6503 ) -> Result<(), LookupServiceError> {
6504 let (txid, oi) = match payload {
6505 OutputAdmittedByTopic::LockingScript {
6506 txid, output_index, ..
6507 } => (txid.clone(), *output_index),
6508 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
6509 ("whole".into(), *output_index)
6510 }
6511 };
6512 self.records.lock().unwrap().push(UTXOReference {
6513 txid,
6514 output_index: oi,
6515 });
6516 Ok(())
6517 }
6518
6519 async fn output_evicted(
6520 &self,
6521 txid: &str,
6522 output_index: u32,
6523 ) -> Result<(), LookupServiceError> {
6524 self.records
6525 .lock()
6526 .unwrap()
6527 .retain(|r| !(r.txid == txid && r.output_index == output_index));
6528 Ok(())
6529 }
6530
6531 async fn lookup(
6532 &self,
6533 _question: &LookupQuestion,
6534 ) -> Result<LookupResult, LookupServiceError> {
6535 Ok(LookupResult::OutputList(
6536 self.records.lock().unwrap().clone(),
6537 ))
6538 }
6539
6540 async fn get_documentation(&self) -> String {
6541 "Mock lookup service".to_string()
6542 }
6543
6544 async fn get_metadata(&self) -> ServiceMetadata {
6545 ServiceMetadata {
6546 name: "mock-ls".to_string(),
6547 ..Default::default()
6548 }
6549 }
6550 }
6551
6552 const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
6556 const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
6557
6558 fn decode_hex(hex: &str) -> Vec<u8> {
6559 (0..hex.len())
6560 .step_by(2)
6561 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
6562 .collect()
6563 }
6564
6565 fn test_beef() -> Vec<u8> {
6566 decode_hex(TEST_BEEF_HEX)
6567 }
6568
6569 fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
6570 TaggedBEEF::new(
6571 test_beef(),
6572 topics.into_iter().map(str::to_string).collect(),
6573 )
6574 }
6575
6576 fn make_engine(admit_indices: Vec<u32>) -> Engine {
6579 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6580 managers.insert(
6581 "tm_test".to_string(),
6582 Box::new(MockTopicManager::admitting(admit_indices)),
6583 );
6584
6585 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
6586 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
6587
6588 let storage = Box::new(MemoryStorage::new());
6589
6590 Engine::new(
6591 managers,
6592 lookup_services,
6593 storage,
6594 None,
6595 EngineConfig::default(),
6596 )
6597 }
6598
6599 #[tokio::test]
6608 async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
6609 use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
6610 use std::cell::RefCell;
6611 use std::rc::Rc;
6612
6613 let parsed = Beef::from_binary(&test_beef()).unwrap();
6616 let parent = parsed.txs[0].tx().unwrap().clone();
6617 let child = parsed.txs[1].tx().unwrap().clone();
6618 let child_id = child.id();
6619 let parent_id = parent.id();
6620 let mut beef = Beef::new();
6621 for bump in &parsed.bumps {
6627 beef.merge_bump(bump.clone());
6628 }
6629 beef.merge_transaction(child);
6630 beef.merge_transaction(parent);
6631 let mut w = bsv_rs::primitives::encoding::Writer::new();
6632 w.write_u32_le(ATOMIC_BEEF);
6633 let mut le = hex::decode(&child_id).unwrap();
6634 le.reverse();
6635 w.write_bytes(&le);
6636 beef.to_writer(&mut w);
6637 let atomic_wire_order = w.into_bytes();
6638
6639 struct RecordingTm(Rc<RefCell<Option<String>>>);
6641 #[async_trait(?Send)]
6642 impl TopicManagerTrait for RecordingTm {
6643 async fn identify_admissible_outputs(
6644 &self,
6645 tx: &Transaction,
6646 _previous_coins: &[u8],
6647 _off_chain_values: Option<&[u8]>,
6648 _mode: SubmitMode,
6649 _context: &TopicAdmittanceContext,
6650 ) -> Result<AdmittanceInstructions, TopicManagerError> {
6651 *self.0.borrow_mut() = Some(tx.id());
6652 Ok(AdmittanceInstructions {
6653 outputs_to_admit: vec![0],
6654 coins_to_retain: vec![],
6655 coins_removed: None,
6656 })
6657 }
6658 async fn get_documentation(&self) -> String {
6659 String::new()
6660 }
6661 async fn get_metadata(&self) -> ServiceMetadata {
6662 ServiceMetadata {
6663 name: "tm_test".to_string(),
6664 ..Default::default()
6665 }
6666 }
6667 }
6668
6669 let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
6670 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6671 managers.insert(
6672 "tm_test".to_string(),
6673 Box::new(RecordingTm(Rc::clone(&seen))),
6674 );
6675 let engine = Engine::new(
6676 managers,
6677 HashMap::new(),
6678 Box::new(MemoryStorage::new()),
6679 None,
6680 EngineConfig::default(),
6681 );
6682
6683 let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
6684 let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
6685
6686 let handed = seen.borrow().clone().expect("TM was not invoked");
6687 assert_eq!(
6688 handed, child_id,
6689 "TM must be handed the atomic subject, got the wire-order pick"
6690 );
6691 assert_ne!(handed, parent_id);
6692 }
6693
6694 #[tokio::test]
6707 async fn history_hydration_preserves_the_stored_proof() {
6708 use bsv_rs::transaction::Beef;
6709
6710 let engine = make_engine(vec![0]);
6711 let stored = test_beef();
6712 let bumps_before = Beef::from_binary(&stored)
6713 .expect("fixture BEEF parses")
6714 .bumps
6715 .len();
6716 assert!(
6717 bumps_before > 0,
6718 "fixture must actually carry a proof, or this cell proves nothing"
6719 );
6720
6721 let output = Output {
6722 txid: TEST_TXID.to_string(),
6723 output_index: 0,
6724 output_script: vec![0x76, 0xa9],
6725 satoshis: 1000,
6726 topic: "tm_test".to_string(),
6727 spent: false,
6728 outputs_consumed: vec![],
6731 consumed_by: vec![],
6732 beef: Some(stored.clone()),
6733 block_height: None,
6734 score: Some(1000.0),
6735 };
6736
6737 let hydrated = engine
6738 .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
6739 .await
6740 .expect("hydration must not error")
6741 .expect("hydration must return the output");
6742 let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
6743 .expect("hydrated BEEF parses");
6744
6745 assert_eq!(
6746 beef.bumps.len(),
6747 bumps_before,
6748 "asking for history must not strip the merkle proof (issue #4)"
6749 );
6750 }
6751
6752 #[tokio::test]
6753 async fn test_submit_unsupported_topic_errors() {
6754 let engine = make_engine(vec![0]);
6755 let beef = test_tagged_beef(vec!["tm_nonexistent"]);
6756
6757 let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
6758 assert!(result.is_err());
6759 assert!(matches!(
6760 result.unwrap_err(),
6761 EngineError::UnsupportedTopic(_)
6762 ));
6763 }
6764
6765 #[tokio::test]
6766 async fn test_submit_admits_outputs() {
6767 let engine = make_engine(vec![0, 1]);
6768 let beef = test_tagged_beef(vec!["tm_test"]);
6769
6770 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6771
6772 let instructions = steak.get("tm_test").unwrap();
6774 assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
6775
6776 let txid = TEST_TXID;
6778 let found = engine
6779 .storage()
6780 .find_output(txid, 0, Some("tm_test"), None, true)
6781 .await
6782 .unwrap()
6783 .expect("Output 0 should be in storage");
6784
6785 assert_eq!(found.txid, TEST_TXID);
6786 assert_eq!(found.satoshis, 26172); assert!(
6788 !found.output_script.is_empty(),
6789 "Script should be populated"
6790 );
6791 assert!(found.beef.is_some(), "BEEF should be stored");
6792 assert!(!found.spent);
6793 }
6794
6795 #[tokio::test]
6796 async fn test_submit_duplicate_is_skipped() {
6797 let engine = make_engine(vec![0]);
6798 let beef = test_tagged_beef(vec!["tm_test"]);
6799
6800 let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6802 assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
6803
6804 let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6806 assert!(steak2["tm_test"].outputs_to_admit.is_empty());
6807 }
6808
6809 #[tokio::test]
6810 async fn test_submit_notifies_lookup_service() {
6811 let engine = make_engine(vec![0]);
6812 let beef = test_tagged_beef(vec!["tm_test"]);
6813
6814 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6815
6816 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6818 let answer = engine.lookup(&question, None).await.unwrap();
6819
6820 match answer {
6821 LookupAnswer::OutputList { outputs } => {
6822 assert_eq!(outputs.len(), 1);
6823 }
6824 _ => panic!("Expected OutputList"),
6825 }
6826 }
6827
6828 #[tokio::test]
6832 async fn test_lookup_with_txids_aligns_txids_with_outputs() {
6833 let engine = make_engine(vec![0]);
6834 let beef = test_tagged_beef(vec!["tm_test"]);
6835 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
6836
6837 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6838 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
6839 match answer {
6840 LookupAnswer::OutputList { outputs } => {
6841 assert_eq!(outputs.len(), 1);
6842 assert_eq!(txids.len(), outputs.len(), "one txid per output");
6843 assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
6844 }
6845 _ => panic!("Expected OutputList"),
6846 }
6847 }
6848
6849 #[tokio::test]
6850 async fn proof_window_holds_an_aged_child_with_a_proven_parent() {
6851 let mut engine = make_engine(vec![0]);
6852 let storage = std::rc::Rc::new(MemoryStorage::new());
6853 engine.storage = Box::new(storage.clone());
6854 engine
6855 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
6856 .await
6857 .unwrap();
6858 assert!(beef_limits::own_proof(&test_beef(), TEST_TXID)
6859 .unwrap()
6860 .is_none());
6861 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6862 storage.advance_clock((crate::proof_window::PROOFLESS_FLAG_MS / 1000) as u64);
6863 assert_eq!(
6864 engine
6865 .lookup_with_txids(&question, None)
6866 .await
6867 .unwrap()
6868 .1
6869 .len(),
6870 1
6871 );
6872 storage.advance_clock(1);
6873 assert!(
6874 engine
6875 .lookup_with_txids(&question, None)
6876 .await
6877 .unwrap()
6878 .1
6879 .is_empty(),
6880 "an aged unproven child must be held even when its parent is proven"
6881 );
6882 }
6883
6884 #[tokio::test]
6885 async fn proof_window_peer_proof_verifies_then_restores_lookup() {
6886 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
6887 let mut engine = make_engine(vec![0]);
6888 let storage = std::rc::Rc::new(MemoryStorage::new());
6889 engine.storage = Box::new(storage.clone());
6890 engine
6891 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
6892 .await
6893 .unwrap();
6894 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
6895 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
6896 let candidate = HeldOutput {
6897 txid: TEST_TXID.into(),
6898 output_index: 0,
6899 topic: "tm_test".into(),
6900 };
6901 let (_, mut remote) = budget_page_engine(1);
6902 remote.node_cost_ms = 0;
6903 remote.nodes = std::rc::Rc::new(HashMap::from([(
6904 TEST_TXID.into(),
6905 crate::types::GASPNode {
6906 graph_id: String::new(),
6907 raw_tx: String::new(),
6908 output_index: 0,
6909 proof: Some(single_leaf_bump_hex(TEST_TXID, 800_000)),
6910 tx_metadata: None,
6911 output_metadata: None,
6912 inputs: None,
6913 },
6914 )]));
6915 engine
6916 .config
6917 .sync_configuration
6918 .insert("tm_test".into(), SyncTarget::Ship);
6919 engine.ship_bootstrap_peers.insert(
6920 "tm_test".into(),
6921 vec!["https://a.example".into(), "https://b.example".into()],
6922 );
6923 engine.set_gasp_remote_factory(Box::new(remote));
6924 let progress = std::cell::Cell::new(PeerProofSummary::default());
6925 engine.chain_tracker = Some(Box::new(bsv_rs::transaction::MockChainTracker::new(
6926 800_001,
6927 )));
6928 engine
6929 .rerequest_held_proofs(std::slice::from_ref(&candidate), &progress)
6930 .await;
6931 assert_eq!(progress.get().requests, 2, "ask every topic peer");
6932 assert_eq!(progress.get().landed, 0);
6933 assert_eq!(progress.get().refused, 2);
6934 assert!(engine
6935 .lookup_with_txids(&question, None)
6936 .await
6937 .unwrap()
6938 .1
6939 .is_empty());
6940 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
6941 tracker.add_root(800_000, TEST_TXID.into());
6942 engine.chain_tracker = Some(Box::new(tracker));
6943 engine.rerequest_held_proofs(&[candidate], &progress).await;
6944 assert_eq!(progress.get().requests, 4);
6945 assert_eq!(
6946 progress.get().landed,
6947 1,
6948 "count durable proofs, not peer answers"
6949 );
6950 let (_, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
6951 assert_eq!(txids, vec![TEST_TXID]);
6952 let stored = storage
6953 .find_output(TEST_TXID, 0, None, None, true)
6954 .await
6955 .unwrap()
6956 .unwrap();
6957 assert!(
6958 beef_limits::own_proof(stored.beef.as_ref().unwrap(), TEST_TXID)
6959 .unwrap()
6960 .is_some()
6961 );
6962 assert_eq!(storage.proven_at_height(TEST_TXID), Some(Some(800_000)));
6963 }
6964
6965 #[tokio::test]
6966 async fn proof_window_peer_hash_leaf_is_stored_as_own_proof() {
6967 use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
6968 let mut engine = make_engine(vec![0]);
6969 let storage = std::rc::Rc::new(MemoryStorage::new());
6970 engine.storage = Box::new(storage.clone());
6971 engine
6972 .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
6973 .await
6974 .unwrap();
6975 storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
6976 let mut proof =
6977 bsv_rs::transaction::MerklePath::from_hex(&single_leaf_bump_hex(TEST_TXID, 800_000))
6978 .unwrap();
6979 proof.path[0][0].txid = false;
6980 let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
6981 tracker.add_root(800_000, proof.compute_root(Some(TEST_TXID)).unwrap());
6982 engine.chain_tracker = Some(Box::new(tracker));
6983 let (_, mut remote) = budget_page_engine(1);
6984 remote.node_cost_ms = 0;
6985 remote.nodes = std::rc::Rc::new(HashMap::from([(
6986 TEST_TXID.into(),
6987 crate::types::GASPNode {
6988 graph_id: String::new(),
6989 raw_tx: String::new(),
6990 output_index: 0,
6991 proof: Some(proof.to_hex()),
6992 tx_metadata: None,
6993 output_metadata: None,
6994 inputs: None,
6995 },
6996 )]));
6997 engine.config.sync_configuration.insert(
6998 "tm_test".into(),
6999 SyncTarget::Peers(vec!["https://peer.example".into()]),
7000 );
7001 engine.set_gasp_remote_factory(Box::new(remote));
7002 let progress = std::cell::Cell::new(PeerProofSummary::default());
7003 engine
7004 .rerequest_held_proofs(
7005 &[HeldOutput {
7006 txid: TEST_TXID.into(),
7007 output_index: 0,
7008 topic: "tm_test".into(),
7009 }],
7010 &progress,
7011 )
7012 .await;
7013 assert_eq!(progress.get().landed, 1);
7014 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7015 let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7016 assert_eq!(txids, vec![TEST_TXID]);
7017 let LookupAnswer::OutputList { outputs } = answer else {
7018 panic!("expected outputs")
7019 };
7020 assert!(
7021 beef_limits::own_proof(&outputs[0].beef, TEST_TXID)
7022 .unwrap()
7023 .is_some(),
7024 "a proven flag must have the child's own path in the served BEEF"
7025 );
7026 }
7027
7028 #[tokio::test]
7029 async fn proof_window_silent_peers_yield_at_the_request_budget() {
7030 use crate::proof_window::{HeldOutput, PeerProofSummary, PEER_PROOF_REQUEST_MS};
7031 let (mut engine, remote) = budget_page_engine(1);
7032 engine.config.sync_configuration.insert(
7033 "tm_test".into(),
7034 SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
7035 );
7036 let elapsed = remote.elapsed.clone();
7037 engine.set_peer_sync_budget(
7038 std::rc::Rc::new(move |ms| {
7039 assert_eq!(ms, PEER_PROOF_REQUEST_MS);
7040 let deadline = elapsed.get() + ms;
7041 let elapsed = elapsed.clone();
7042 Box::pin(std::future::poll_fn(move |_| {
7043 if elapsed.get() >= deadline {
7044 std::task::Poll::Ready(())
7045 } else {
7046 std::task::Poll::Pending
7047 }
7048 }))
7049 }),
7050 30_000,
7051 );
7052 let candidate = HeldOutput {
7053 txid: remote.outputs[0].txid.clone(),
7054 output_index: 0,
7055 topic: "tm_test".into(),
7056 };
7057 engine.set_gasp_remote_factory(Box::new(remote));
7058 let progress = std::cell::Cell::new(PeerProofSummary::default());
7059 engine.rerequest_held_proofs(&[candidate], &progress).await;
7060 assert_eq!(progress.get().requests, 2);
7061 assert_eq!(progress.get().faults, 2);
7062 assert_eq!(progress.get().landed, 0);
7063 }
7064
7065 #[test]
7067 fn sync_limit_clamp() {
7068 assert_eq!(
7069 Engine::clamp_sync_limit(None),
7070 Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
7071 "absent limit gets the default page, never unbounded"
7072 );
7073 assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
7074 assert_eq!(
7075 Engine::clamp_sync_limit(Some(u64::MAX)),
7076 Engine::SYNC_RESPONSE_MAX_LIMIT,
7077 "a huge caller-supplied limit is capped"
7078 );
7079 }
7080
7081 #[tokio::test]
7082 async fn test_lookup_unknown_service_errors() {
7083 let engine = make_engine(vec![]);
7084 let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
7085
7086 let result = engine.lookup(&question, None).await;
7087 assert!(result.is_err());
7088 assert!(matches!(
7089 result.unwrap_err(),
7090 EngineError::LookupServiceNotFound(_)
7091 ));
7092 }
7093
7094 #[tokio::test]
7095 async fn test_list_topic_managers() {
7096 let engine = make_engine(vec![]);
7097 let managers = engine.list_topic_managers().await;
7098
7099 assert_eq!(managers.len(), 1);
7100 assert!(managers.contains_key("tm_test"));
7101 assert_eq!(managers["tm_test"].name, "mock-tm");
7102 }
7103
7104 #[tokio::test]
7105 async fn test_list_lookup_service_providers() {
7106 let engine = make_engine(vec![]);
7107 let services = engine.list_lookup_service_providers().await;
7108
7109 assert_eq!(services.len(), 1);
7110 assert!(services.contains_key("ls_test"));
7111 assert_eq!(services["ls_test"].name, "mock-ls");
7112 }
7113
7114 #[tokio::test]
7115 async fn test_get_documentation() {
7116 let engine = make_engine(vec![]);
7117
7118 let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
7119 assert_eq!(tm_docs, "Mock topic manager");
7120
7121 let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
7122 assert_eq!(ls_docs, "Mock lookup service");
7123
7124 let missing = engine
7125 .get_documentation_for_topic_manager("tm_missing")
7126 .await;
7127 assert_eq!(missing, "No documentation found!");
7128 }
7129
7130 #[tokio::test]
7131 async fn test_provide_foreign_sync_response() {
7132 let engine = make_engine(vec![0]);
7133
7134 let beef = test_tagged_beef(vec!["tm_test"]);
7136 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7137
7138 let request = GASPInitialRequest {
7140 version: 1,
7141 since: 0,
7142 limit: Some(100),
7143 };
7144 let response = engine
7145 .provide_foreign_sync_response(&request, "tm_test")
7146 .await
7147 .unwrap();
7148
7149 assert_eq!(response.utxo_list.len(), 1);
7151 assert_eq!(response.utxo_list[0].txid, TEST_TXID);
7152 assert_eq!(response.since, 0);
7153 }
7154
7155 #[tokio::test]
7156 async fn test_delete_utxo_deep_simple() {
7157 let engine = make_engine(vec![0]);
7158 let beef = test_tagged_beef(vec!["tm_test"]);
7159
7160 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7161
7162 let txid = TEST_TXID;
7163 let output = engine
7164 .storage()
7165 .find_output(txid, 0, Some("tm_test"), None, false)
7166 .await
7167 .unwrap()
7168 .unwrap();
7169
7170 engine.delete_utxo_deep(&output, None).await.unwrap();
7172
7173 let found = engine
7175 .storage()
7176 .find_output(txid, 0, Some("tm_test"), None, false)
7177 .await
7178 .unwrap();
7179 assert!(found.is_none());
7180 }
7181
7182 #[tokio::test]
7183 async fn test_sync_config_defaults_to_ship() {
7184 let engine = make_engine(vec![]);
7185 let sync = &engine.config.sync_configuration;
7186
7187 assert!(sync.contains_key("tm_test"));
7189 assert!(matches!(sync["tm_test"], SyncTarget::Ship));
7190 }
7191
7192 #[tokio::test]
7193 async fn test_multiple_topics_in_single_submit() {
7194 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7195 managers.insert(
7196 "tm_alpha".to_string(),
7197 Box::new(MockTopicManager::admitting(vec![0])),
7198 );
7199 managers.insert(
7200 "tm_beta".to_string(),
7201 Box::new(MockTopicManager::admitting(vec![1])),
7202 );
7203
7204 let engine = Engine::new(
7205 managers,
7206 HashMap::new(),
7207 Box::new(MemoryStorage::new()),
7208 None,
7209 EngineConfig::default(),
7210 );
7211
7212 let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
7213
7214 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7215
7216 assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
7217 assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
7218 }
7219
7220 #[tokio::test]
7221 async fn test_evict_output_with_topic() {
7222 let engine = make_engine(vec![0]);
7223 let beef = test_tagged_beef(vec!["tm_test"]);
7224 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7225
7226 let found = engine
7228 .storage()
7229 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7230 .await
7231 .unwrap();
7232 assert!(found.is_some(), "Output should exist before eviction");
7233
7234 engine
7236 .evict_output(TEST_TXID, 0, Some("tm_test"))
7237 .await
7238 .unwrap();
7239
7240 let found = engine
7242 .storage()
7243 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7244 .await
7245 .unwrap();
7246 assert!(found.is_none(), "Output should be gone after eviction");
7247
7248 let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7250 let answer = engine.lookup(&question, None).await.unwrap();
7251 match answer {
7252 LookupAnswer::OutputList { outputs } => {
7253 assert!(
7254 outputs.is_empty(),
7255 "Lookup should return empty after eviction"
7256 );
7257 }
7258 _ => panic!("Expected OutputList"),
7259 }
7260 }
7261
7262 #[tokio::test]
7263 async fn test_evict_output_without_topic() {
7264 let engine = make_engine(vec![0]);
7265 let beef = test_tagged_beef(vec!["tm_test"]);
7266 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7267
7268 engine.evict_output(TEST_TXID, 0, None).await.unwrap();
7270
7271 let found = engine
7272 .storage()
7273 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7274 .await
7275 .unwrap();
7276 assert!(
7277 found.is_none(),
7278 "Output should be gone after topic-less eviction"
7279 );
7280 }
7281
7282 #[tokio::test]
7283 async fn test_evict_nonexistent_output_is_ok() {
7284 let engine = make_engine(vec![]);
7285
7286 let result = engine
7288 .evict_output("nonexistent_txid", 99, Some("tm_test"))
7289 .await;
7290 assert!(result.is_ok(), "Evicting nonexistent output should succeed");
7291 }
7292
7293 use crate::broadcaster::Broadcaster;
7296 use std::sync::Arc;
7297
7298 type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
7299
7300 struct MockBroadcaster {
7301 calls: BroadcastCallLog,
7302 }
7303
7304 impl MockBroadcaster {
7305 fn new() -> Self {
7306 Self {
7307 calls: Arc::new(Mutex::new(Vec::new())),
7308 }
7309 }
7310
7311 #[allow(dead_code, reason = "kept for future test extension")]
7312 fn call_count(&self) -> usize {
7313 self.calls.lock().unwrap().len()
7314 }
7315
7316 #[allow(dead_code, reason = "kept for future test extension")]
7317 fn calls(&self) -> Vec<(String, Vec<String>)> {
7318 self.calls.lock().unwrap().clone()
7319 }
7320 }
7321
7322 #[async_trait(?Send)]
7323 impl Broadcaster for MockBroadcaster {
7324 async fn broadcast_to_host(
7325 &self,
7326 host_url: &str,
7327 tagged_beef: &TaggedBEEF,
7328 ) -> Result<(), String> {
7329 self.calls
7330 .lock()
7331 .unwrap()
7332 .push((host_url.to_string(), tagged_beef.topics.clone()));
7333 Ok(())
7334 }
7335 }
7336
7337 struct MockSHIPLookupService {
7343 records: Mutex<HashMap<String, Vec<UTXOReference>>>,
7345 }
7346
7347 impl MockSHIPLookupService {
7348 fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
7349 Self {
7350 records: Mutex::new(records),
7351 }
7352 }
7353 }
7354
7355 #[async_trait(?Send)]
7356 impl LookupServiceTrait for MockSHIPLookupService {
7357 fn admission_mode(&self) -> AdmissionMode {
7358 AdmissionMode::LockingScript
7359 }
7360
7361 fn spend_notification_mode(&self) -> SpendNotificationMode {
7362 SpendNotificationMode::None
7363 }
7364
7365 async fn output_admitted_by_topic(
7366 &self,
7367 _payload: &OutputAdmittedByTopic,
7368 ) -> Result<(), LookupServiceError> {
7369 Ok(())
7370 }
7371
7372 async fn output_evicted(
7373 &self,
7374 _txid: &str,
7375 _output_index: u32,
7376 ) -> Result<(), LookupServiceError> {
7377 Ok(())
7378 }
7379
7380 async fn lookup(
7381 &self,
7382 question: &LookupQuestion,
7383 ) -> Result<LookupResult, LookupServiceError> {
7384 if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
7386 let records = self.records.lock().unwrap();
7387 let mut results = Vec::new();
7388 for topic_val in topics {
7389 if let Some(topic) = topic_val.as_str() {
7390 if let Some(refs) = records.get(topic) {
7391 results.extend(refs.iter().cloned());
7392 }
7393 }
7394 }
7395 return Ok(LookupResult::OutputList(results));
7396 }
7397 Ok(LookupResult::OutputList(vec![]))
7398 }
7399
7400 async fn get_documentation(&self) -> String {
7401 "Mock SHIP lookup service".to_string()
7402 }
7403
7404 async fn get_metadata(&self) -> ServiceMetadata {
7405 ServiceMetadata {
7406 name: "mock-ls-ship".to_string(),
7407 ..Default::default()
7408 }
7409 }
7410 }
7411
7412 fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
7414 use bsv_rs::script::templates::PushDrop;
7415 use bsv_rs::PublicKey;
7416
7417 let fields = vec![
7418 b"SHIP".to_vec(),
7419 vec![0x02; 33], domain.as_bytes().to_vec(),
7421 topic.as_bytes().to_vec(),
7422 ];
7423
7424 let pubkey = PublicKey::from_hex(
7427 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7428 )
7429 .expect("valid test pubkey");
7430
7431 let pd = PushDrop::new(pubkey, fields);
7432 pd.lock().to_binary()
7433 }
7434
7435 async fn make_engine_with_broadcaster(
7438 admit_indices: Vec<u32>,
7439 broadcaster: MockBroadcaster,
7440 ship_domains: Vec<(&str, &str)>,
7441 hosting_url: Option<&str>,
7442 ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
7443 let calls = broadcaster.calls.clone();
7444
7445 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7446 managers.insert(
7447 "tm_test".to_string(),
7448 Box::new(MockTopicManager::admitting(admit_indices)),
7449 );
7450
7451 let storage = Box::new(MemoryStorage::new());
7452
7453 let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
7457
7458 for (domain, topic) in &ship_domains {
7459 let fake_txid = format!("{:064x}", {
7461 let mut h: u64 = 0;
7462 for b in domain.bytes().chain(topic.bytes()) {
7463 h = h.wrapping_mul(31).wrapping_add(u64::from(b));
7464 }
7465 h
7466 });
7467
7468 ship_records
7470 .entry(topic.to_string())
7471 .or_default()
7472 .push(UTXOReference {
7473 txid: fake_txid.clone(),
7474 output_index: 0,
7475 });
7476
7477 let script = build_ship_pushdrop_script(domain, topic);
7479
7480 let output = Output {
7481 txid: fake_txid,
7482 output_index: 0,
7483 output_script: script,
7484 satoshis: 1,
7485 topic: "tm_ship".to_string(),
7486 spent: false,
7487 outputs_consumed: vec![],
7488 consumed_by: vec![],
7489 beef: None,
7490 block_height: None,
7491 score: None,
7492 };
7493
7494 storage.insert_output(&output).await.unwrap();
7495 }
7496
7497 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7498 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7499 lookup_services.insert(
7500 "ls_ship".to_string(),
7501 Box::new(MockSHIPLookupService::with_records(ship_records)),
7502 );
7503
7504 let config = EngineConfig {
7505 hosting_url: hosting_url.map(str::to_string),
7506 ..Default::default()
7507 };
7508
7509 let engine = Engine::with_chain_tracker(
7510 managers,
7511 lookup_services,
7512 storage,
7513 None,
7514 Some(Box::new(broadcaster)),
7515 None,
7516 config,
7517 );
7518
7519 (engine, calls)
7520 }
7521
7522 #[tokio::test]
7525 async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
7526 let broadcaster = MockBroadcaster::new();
7527 let (engine, calls) = make_engine_with_broadcaster(
7528 vec![0],
7529 broadcaster,
7530 vec![("https://peer1.example.com", "tm_test")],
7531 Some("https://self.example.com"),
7532 )
7533 .await;
7534
7535 let beef = test_tagged_beef(vec!["tm_test"]);
7536 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7537
7538 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7540
7541 let recorded = calls.lock().unwrap();
7543 assert_eq!(
7544 recorded.len(),
7545 1,
7546 "Broadcaster should be called once for the one SHIP peer"
7547 );
7548 assert_eq!(recorded[0].0, "https://peer1.example.com");
7549 }
7550
7551 #[tokio::test]
7552 async fn test_broadcaster_not_called_on_historical_tx() {
7553 let broadcaster = MockBroadcaster::new();
7554 let (engine, calls) = make_engine_with_broadcaster(
7555 vec![0],
7556 broadcaster,
7557 vec![("https://peer1.example.com", "tm_test")],
7558 None,
7559 )
7560 .await;
7561
7562 let beef = test_tagged_beef(vec!["tm_test"]);
7563 let steak = engine
7564 .submit(&beef, SubmitMode::HistoricalTx)
7565 .await
7566 .unwrap();
7567
7568 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7569
7570 assert_eq!(
7572 calls.lock().unwrap().len(),
7573 0,
7574 "Broadcaster should not be called for historical TX"
7575 );
7576 }
7577
7578 #[tokio::test]
7579 async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
7580 let broadcaster = MockBroadcaster::new();
7581 let (engine, calls) = make_engine_with_broadcaster(
7582 vec![0],
7583 broadcaster,
7584 vec![("https://peer1.example.com", "tm_test")],
7585 None,
7586 )
7587 .await;
7588
7589 let beef = test_tagged_beef(vec!["tm_test"]);
7590 let steak = engine
7591 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
7592 .await
7593 .unwrap();
7594
7595 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7596
7597 assert_eq!(
7598 calls.lock().unwrap().len(),
7599 0,
7600 "Broadcaster should not be called for historical-tx-no-spv"
7601 );
7602 }
7603
7604 #[tokio::test]
7605 async fn test_broadcaster_skips_self_hosting_url() {
7606 let broadcaster = MockBroadcaster::new();
7607 let (engine, calls) = make_engine_with_broadcaster(
7608 vec![0],
7609 broadcaster,
7610 vec![
7611 ("https://self.example.com", "tm_test"),
7612 ("https://peer2.example.com", "tm_test"),
7613 ],
7614 Some("https://self.example.com"),
7615 )
7616 .await;
7617
7618 let beef = test_tagged_beef(vec!["tm_test"]);
7619 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7620
7621 let recorded = calls.lock().unwrap();
7623 assert_eq!(
7624 recorded.len(),
7625 1,
7626 "Should broadcast to peer2 only, skipping self"
7627 );
7628 assert_eq!(recorded[0].0, "https://peer2.example.com");
7629 }
7630
7631 #[tokio::test]
7632 async fn test_broadcaster_not_called_when_no_outputs_admitted() {
7633 let broadcaster = MockBroadcaster::new();
7634 let (engine, calls) = make_engine_with_broadcaster(
7636 vec![],
7637 broadcaster,
7638 vec![("https://peer1.example.com", "tm_test")],
7639 None,
7640 )
7641 .await;
7642
7643 let beef = test_tagged_beef(vec!["tm_test"]);
7644 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7645
7646 assert!(steak["tm_test"].outputs_to_admit.is_empty());
7647
7648 assert_eq!(
7650 calls.lock().unwrap().len(),
7651 0,
7652 "Broadcaster should not be called when no outputs are admitted"
7653 );
7654 }
7655
7656 #[tokio::test]
7657 async fn test_broadcaster_not_called_when_no_ship_peers() {
7658 let broadcaster = MockBroadcaster::new();
7659 let (engine, calls) = make_engine_with_broadcaster(
7661 vec![0],
7662 broadcaster,
7663 vec![], None,
7665 )
7666 .await;
7667
7668 let beef = test_tagged_beef(vec!["tm_test"]);
7669 engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7670
7671 assert_eq!(
7672 calls.lock().unwrap().len(),
7673 0,
7674 "Broadcaster should not be called when no SHIP peers exist"
7675 );
7676 }
7677
7678 struct SpendModeLookupService {
7684 mode: SpendNotificationMode,
7685 records: Mutex<Vec<UTXOReference>>,
7686 spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
7687 }
7688
7689 impl SpendModeLookupService {
7690 fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
7691 Self {
7692 mode,
7693 records: Mutex::new(Vec::new()),
7694 spent_payloads: capture,
7695 }
7696 }
7697 }
7698
7699 #[async_trait(?Send)]
7700 impl LookupServiceTrait for SpendModeLookupService {
7701 fn admission_mode(&self) -> AdmissionMode {
7702 AdmissionMode::LockingScript
7703 }
7704
7705 fn spend_notification_mode(&self) -> SpendNotificationMode {
7706 self.mode
7707 }
7708
7709 async fn output_admitted_by_topic(
7710 &self,
7711 payload: &OutputAdmittedByTopic,
7712 ) -> Result<(), LookupServiceError> {
7713 let (txid, oi) = match payload {
7714 OutputAdmittedByTopic::LockingScript {
7715 txid, output_index, ..
7716 } => (txid.clone(), *output_index),
7717 OutputAdmittedByTopic::WholeTx { output_index, .. } => {
7718 ("whole".into(), *output_index)
7719 }
7720 };
7721 self.records.lock().unwrap().push(UTXOReference {
7722 txid,
7723 output_index: oi,
7724 });
7725 Ok(())
7726 }
7727
7728 async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
7729 self.spent_payloads.lock().unwrap().push(payload.clone());
7730 Ok(())
7731 }
7732
7733 async fn output_evicted(
7734 &self,
7735 txid: &str,
7736 output_index: u32,
7737 ) -> Result<(), LookupServiceError> {
7738 self.records
7739 .lock()
7740 .unwrap()
7741 .retain(|r| !(r.txid == txid && r.output_index == output_index));
7742 Ok(())
7743 }
7744
7745 async fn lookup(
7746 &self,
7747 _question: &LookupQuestion,
7748 ) -> Result<LookupResult, LookupServiceError> {
7749 Ok(LookupResult::OutputList(
7750 self.records.lock().unwrap().clone(),
7751 ))
7752 }
7753
7754 async fn get_documentation(&self) -> String {
7755 "SpendMode mock lookup service".to_string()
7756 }
7757
7758 async fn get_metadata(&self) -> ServiceMetadata {
7759 ServiceMetadata {
7760 name: "spend-mode-ls".to_string(),
7761 ..Default::default()
7762 }
7763 }
7764 }
7765
7766 const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
7768
7769 async fn make_spend_mode_engine(
7772 mode: SpendNotificationMode,
7773 ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
7774 let storage = MemoryStorage::new();
7775
7776 let prev_output = Output {
7778 txid: PREVIOUS_TXID.to_string(),
7779 output_index: 0,
7780 output_script: vec![0x76, 0xa9],
7781 satoshis: 26174,
7782 topic: "tm_test".to_string(),
7783 spent: false,
7784 outputs_consumed: vec![],
7785 consumed_by: vec![],
7786 beef: Some(test_beef()),
7787 block_height: None,
7788 score: Some(1000.0),
7789 };
7790 storage.insert_output(&prev_output).await.unwrap();
7791
7792 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7793 managers.insert(
7794 "tm_test".to_string(),
7795 Box::new(MockTopicManager::admitting(vec![0])),
7796 );
7797
7798 let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
7799 let ls = SpendModeLookupService::with_mode(mode, capture.clone());
7800 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7801 lookup_services.insert("ls_test".to_string(), Box::new(ls));
7802
7803 let engine = Engine::new(
7804 managers,
7805 lookup_services,
7806 Box::new(storage),
7807 None,
7808 EngineConfig::default(),
7809 );
7810
7811 (engine, capture)
7812 }
7813
7814 #[tokio::test]
7815 async fn test_spend_notification_mode_script() {
7816 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
7817
7818 let beef = test_tagged_beef(vec!["tm_test"]);
7819 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7820
7821 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7823
7824 let payloads = capture.lock().unwrap().clone();
7826 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7827
7828 match &payloads[0] {
7829 OutputSpent::Script {
7830 txid,
7831 output_index,
7832 topic,
7833 spending_txid,
7834 input_index,
7835 unlocking_script,
7836 sequence_number,
7837 ..
7838 } => {
7839 assert_eq!(txid, PREVIOUS_TXID);
7840 assert_eq!(*output_index, 0);
7841 assert_eq!(topic, "tm_test");
7842 assert_eq!(spending_txid, TEST_TXID);
7843 assert_eq!(*input_index, 0);
7845 assert!(
7847 !unlocking_script.is_empty(),
7848 "Unlocking script should be non-empty"
7849 );
7850 assert_eq!(*sequence_number, 0xffff_ffff);
7852 }
7853 other => panic!("Expected OutputSpent::Script, got: {other:?}"),
7854 }
7855 }
7856
7857 #[tokio::test]
7858 async fn test_spend_notification_mode_whole_tx() {
7859 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
7860
7861 let beef = test_tagged_beef(vec!["tm_test"]);
7862 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7863
7864 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7865
7866 let payloads = capture.lock().unwrap().clone();
7867 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7868
7869 match &payloads[0] {
7870 OutputSpent::WholeTx {
7871 txid,
7872 output_index,
7873 topic,
7874 spending_atomic_beef,
7875 ..
7876 } => {
7877 assert_eq!(txid, PREVIOUS_TXID);
7878 assert_eq!(*output_index, 0);
7879 assert_eq!(topic, "tm_test");
7880 let mut named =
7883 bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
7884 assert!(named.is_atomic());
7885 let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
7886 assert_eq!(
7887 named.txs.len(),
7888 submitted.txs.len(),
7889 "the whole body, never pruned"
7890 );
7891 let tip = crate::subject::subject_txid_of(&mut named).unwrap();
7892 assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
7893 }
7894 other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
7895 }
7896 }
7897
7898 #[tokio::test]
7899 async fn test_spend_notification_mode_txid() {
7900 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
7901
7902 let beef = test_tagged_beef(vec!["tm_test"]);
7903 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7904
7905 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7906
7907 let payloads = capture.lock().unwrap().clone();
7908 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7909
7910 match &payloads[0] {
7911 OutputSpent::Txid {
7912 txid,
7913 output_index,
7914 topic,
7915 spending_txid,
7916 } => {
7917 assert_eq!(txid, PREVIOUS_TXID);
7918 assert_eq!(*output_index, 0);
7919 assert_eq!(topic, "tm_test");
7920 assert_eq!(spending_txid, TEST_TXID);
7921 }
7922 other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
7923 }
7924 }
7925
7926 #[tokio::test]
7927 async fn test_spend_notification_mode_none() {
7928 let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
7929
7930 let beef = test_tagged_beef(vec!["tm_test"]);
7931 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7932
7933 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
7934
7935 let payloads = capture.lock().unwrap().clone();
7936 assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
7937
7938 match &payloads[0] {
7939 OutputSpent::None {
7940 txid,
7941 output_index,
7942 topic,
7943 } => {
7944 assert_eq!(txid, PREVIOUS_TXID);
7945 assert_eq!(*output_index, 0);
7946 assert_eq!(topic, "tm_test");
7947 }
7948 other => panic!("Expected OutputSpent::None, got: {other:?}"),
7949 }
7950 }
7951
7952 #[tokio::test]
7955 async fn test_start_gasp_sync_empty_config() {
7956 let engine = Engine::new(
7957 HashMap::new(),
7958 HashMap::new(),
7959 Box::new(MemoryStorage::new()),
7960 None,
7961 EngineConfig {
7962 sync_configuration: HashMap::new(),
7963 ..Default::default()
7964 },
7965 );
7966
7967 let result = engine.start_gasp_sync().await.unwrap();
7968 assert!(
7969 result.topics_synced.is_empty(),
7970 "No topics should be synced with empty config"
7971 );
7972 }
7973
7974 #[tokio::test]
7975 async fn test_start_gasp_sync_disabled_topic_skipped() {
7976 let mut sync_config: SyncConfiguration = HashMap::new();
7977 sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
7978
7979 let engine = Engine::new(
7980 HashMap::new(),
7981 HashMap::new(),
7982 Box::new(MemoryStorage::new()),
7983 None,
7984 EngineConfig {
7985 sync_configuration: sync_config,
7986 ..Default::default()
7987 },
7988 );
7989
7990 let result = engine.start_gasp_sync().await.unwrap();
7991 assert!(
7992 result.topics_synced.is_empty(),
7993 "Disabled topics should not appear in results"
7994 );
7995 }
7996
7997 #[tokio::test]
7998 async fn test_start_gasp_sync_peers_config() {
7999 let mut sync_config: SyncConfiguration = HashMap::new();
8000 sync_config.insert(
8001 "tm_test".to_string(),
8002 SyncTarget::Peers(vec![
8003 "https://peer1.example.com".to_string(),
8004 "https://peer2.example.com".to_string(),
8005 ]),
8006 );
8007
8008 let engine = Engine::new(
8009 HashMap::new(),
8010 HashMap::new(),
8011 Box::new(MemoryStorage::new()),
8012 None,
8013 EngineConfig {
8014 sync_configuration: sync_config,
8015 ..Default::default()
8016 },
8017 );
8018
8019 let result = engine.start_gasp_sync().await.unwrap();
8020 assert_eq!(result.topics_synced.len(), 1);
8021
8022 let topic_result = &result.topics_synced["tm_test"];
8023 assert_eq!(topic_result.peers.len(), 2);
8024 assert_eq!(topic_result.sync_type, "peers");
8025 assert!(topic_result.errors.is_empty());
8026 }
8027
8028 #[tokio::test]
8029 async fn test_start_gasp_sync_peers_filters_self() {
8030 let mut sync_config: SyncConfiguration = HashMap::new();
8031 sync_config.insert(
8032 "tm_test".to_string(),
8033 SyncTarget::Peers(vec![
8034 "https://self.example.com".to_string(),
8035 "https://peer2.example.com".to_string(),
8036 ]),
8037 );
8038
8039 let engine = Engine::new(
8040 HashMap::new(),
8041 HashMap::new(),
8042 Box::new(MemoryStorage::new()),
8043 None,
8044 EngineConfig {
8045 hosting_url: Some("https://self.example.com".to_string()),
8046 sync_configuration: sync_config,
8047 ..Default::default()
8048 },
8049 );
8050
8051 let result = engine.start_gasp_sync().await.unwrap();
8052 let topic_result = &result.topics_synced["tm_test"];
8053 assert_eq!(
8054 topic_result.peers.len(),
8055 1,
8056 "Self URL should be filtered out"
8057 );
8058 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
8059 }
8060
8061 #[derive(Clone, Default)]
8062 struct MockShipLookupClient {
8063 records: HashMap<(String, String), Vec<OutputListItem>>,
8064 failures: HashSet<String>,
8065 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
8066 }
8067
8068 struct RecordingDiscoverySyncFactory {
8069 calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
8070 }
8071
8072 impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
8073 fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8074 self.calls
8075 .borrow_mut()
8076 .push((peer.to_string(), topic.to_string()));
8077 Box::new(MockSyncRemote { utxos: Vec::new() })
8078 }
8079 }
8080
8081 #[async_trait(?Send)]
8082 impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
8083 async fn lookup(
8084 &self,
8085 host: &str,
8086 question: &LookupQuestion,
8087 ) -> Result<Vec<OutputListItem>, String> {
8088 self.calls
8089 .borrow_mut()
8090 .push((host.to_string(), question.clone()));
8091 if self.failures.contains(host) {
8092 return Err("mock tracker unavailable".to_string());
8093 }
8094 Ok(self
8095 .records
8096 .get(&(host.to_string(), question.service.clone()))
8097 .cloned()
8098 .unwrap_or_default())
8099 }
8100 }
8101
8102 fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
8103 use bsv_rs::script::templates::PushDrop;
8104 let pubkey = bsv_rs::PublicKey::from_hex(
8105 "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
8106 )
8107 .unwrap();
8108 let script = PushDrop::new(
8109 pubkey,
8110 vec![
8111 protocol.as_bytes().to_vec(),
8112 vec![0x02; 33],
8113 domain.as_bytes().to_vec(),
8114 scope.as_bytes().to_vec(),
8115 ],
8116 )
8117 .lock();
8118 let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
8119 tx.outputs[0].locking_script = script;
8120 tx.merkle_path = None;
8121 tx.invalidate_caches();
8122 let beef = tx.to_atomic_beef(true).unwrap();
8123 let parsed =
8124 beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
8125 .unwrap();
8126 assert_eq!(
8127 parsed.outputs[0].locking_script,
8128 tx.outputs[0].locking_script
8129 );
8130 OutputListItem {
8131 beef,
8132 output_index: 0,
8133 context: None,
8134 }
8135 }
8136
8137 #[tokio::test]
8138 async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
8139 let (mut engine, _) = make_engine_with_broadcaster(
8140 vec![0],
8141 MockBroadcaster::new(),
8142 vec![
8143 ("https://local.example", "tm_test"),
8144 ("https://shared.example/?local", "tm_test"),
8145 ],
8146 Some("https://self.example"),
8147 )
8148 .await;
8149 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8150 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8151 let mut client = MockShipLookupClient::default();
8152 client.records.insert(
8153 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8154 vec![
8155 discovery_output("SHIP", "https://direct.example", "tm_test"),
8156 discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
8157 discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
8158 discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
8159 discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
8160 OutputListItem {
8161 beef: vec![1, 2, 3],
8162 output_index: 0,
8163 context: None,
8164 },
8165 ],
8166 );
8167 client.records.insert(
8168 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8169 vec![
8170 discovery_output("SLAP", "https://lookup.example", "ls_ship"),
8171 discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
8172 discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
8173 discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
8174 discovery_output("SLAP", "https://other-service.example", "ls_other"),
8175 ],
8176 );
8177 client.records.insert(
8178 ("https://lookup.example".to_string(), "ls_ship".to_string()),
8179 vec![
8180 discovery_output("SHIP", "https://indirect.example", "tm_test"),
8181 discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
8182 ],
8183 );
8184 let calls = client.calls.clone();
8185 engine.set_ship_lookup_client(Box::new(client));
8186 let result = engine.start_gasp_sync().await.unwrap();
8187 assert_eq!(
8188 result.topics_synced["tm_test"].peers,
8189 vec![
8190 "https://bootstrap.example",
8191 "https://direct.example",
8192 "https://indirect.example",
8193 "https://local.example",
8194 "https://shared.example",
8195 ]
8196 );
8197 let calls = calls.borrow();
8198 assert_eq!(calls.len(), 3);
8199 for (_, question) in calls.iter() {
8200 let expected = if question.service == "ls_slap" {
8201 serde_json::json!({"service": "ls_ship"})
8202 } else {
8203 serde_json::json!({"topics": ["tm_test"]})
8204 };
8205 assert_eq!(question.query, expected);
8206 }
8207 }
8208
8209 #[tokio::test]
8210 async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
8211 let (mut engine, _) =
8212 make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
8213 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8214 let mut client = MockShipLookupClient::default();
8215 let outputs = (0..9)
8216 .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
8217 .collect();
8218 client.records.insert(
8219 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8220 outputs,
8221 );
8222 engine.set_ship_lookup_client(Box::new(client));
8223 let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8224 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8225 calls: sync_calls.clone(),
8226 }));
8227 let result = engine.start_gasp_sync().await.unwrap();
8228 let topic = &result.topics_synced["tm_test"];
8229 assert_eq!(topic.sync_type, "ship");
8230 assert_eq!(
8231 topic.peers.len(),
8232 9,
8233 "the lookup-host cap must not truncate sync peers"
8234 );
8235 assert!(topic.errors.is_empty());
8236 assert_eq!(sync_calls.borrow().len(), 9);
8237 for peer in &topic.peers {
8238 assert!(sync_calls
8239 .borrow()
8240 .contains(&(peer.clone(), "tm_test".to_string())));
8241 }
8242 }
8243
8244 #[tokio::test]
8245 async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
8246 let mut engine = make_engine(vec![]);
8247 engine.config.slap_trackers = vec![
8248 "https://dead.example".to_string(),
8249 "https://live.example".to_string(),
8250 ];
8251 let mut client = MockShipLookupClient::default();
8252 client.failures.insert("https://dead.example".to_string());
8253 client.records.insert(
8254 ("https://live.example".to_string(), "ls_ship".to_string()),
8255 vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
8256 );
8257 engine.set_ship_lookup_client(Box::new(client));
8258 let result = engine.start_gasp_sync().await.unwrap();
8259 assert_eq!(
8260 result.topics_synced["tm_test"].peers,
8261 vec!["https://peer.example"]
8262 );
8263 }
8264
8265 #[tokio::test]
8266 async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
8267 use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
8268 let mut engine = make_engine(vec![]);
8269 engine
8270 .config
8271 .sync_configuration
8272 .insert("tm_other".to_string(), SyncTarget::Ship);
8273 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8274 let mut client = MockShipLookupClient::default();
8275 let mut providers = Vec::new();
8276 for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
8277 let host = format!("https://lookup{n}.example");
8278 providers.push(discovery_output("SLAP", &host, "ls_ship"));
8279 client.records.insert(
8280 (host, "ls_ship".to_string()),
8281 vec![
8282 discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
8283 discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
8284 ],
8285 );
8286 }
8287 client.records.insert(
8288 ("https://tracker.example".to_string(), "ls_slap".to_string()),
8289 providers,
8290 );
8291 let calls = client.calls.clone();
8292 engine.set_ship_lookup_client(Box::new(client));
8293 let result = engine.start_gasp_sync().await.unwrap();
8294 assert_eq!(
8295 result.topics_synced["tm_test"].peers.len(),
8296 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8297 );
8298 assert_eq!(
8299 result.topics_synced["tm_other"].peers.len(),
8300 SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8301 );
8302 let calls = calls.borrow();
8303 assert_eq!(
8304 calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
8305 1
8306 );
8307 let providers: HashSet<_> = calls
8308 .iter()
8309 .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
8310 .map(|(h, _)| h)
8311 .collect();
8312 assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
8313 assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
8314 }
8315
8316 #[tokio::test]
8317 async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
8318 let mut engine = make_engine(vec![]);
8319 engine.config.hosting_url = Some("https://self.example".to_string());
8320 engine.config.slap_trackers = vec!["https://dead.example".to_string()];
8321 let mut client = MockShipLookupClient::default();
8322 client.failures.insert("https://dead.example".to_string());
8323 engine.set_ship_lookup_client(Box::new(client));
8324 engine.set_ship_bootstrap_peers(
8325 "tm_test",
8326 vec![
8327 "https://bootstrap.example".to_string(),
8328 "https://BOOTSTRAP.example.:443/?x".to_string(),
8329 "http://SELF.example.:8443/path".to_string(),
8330 ],
8331 );
8332 let result = engine.start_gasp_sync().await.unwrap();
8333 assert_eq!(
8334 result.topics_synced["tm_test"].peers,
8335 vec!["https://bootstrap.example"]
8336 );
8337 }
8338
8339 #[tokio::test]
8340 async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
8341 let mut engine = make_engine(vec![]);
8342 for topic in ["tm_ship", "tm_slap"] {
8343 engine.config.sync_configuration.insert(
8344 topic.to_string(),
8345 SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
8346 );
8347 }
8348 engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8349 let mut client = MockShipLookupClient::default();
8350 client.records.insert(
8351 ("https://tracker.example".to_string(), "ls_ship".to_string()),
8352 vec![discovery_output(
8353 "SHIP",
8354 "https://discovered.example",
8355 "tm_test",
8356 )],
8357 );
8358 let lookups = client.calls.clone();
8359 engine.set_ship_lookup_client(Box::new(client));
8360 engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8361 let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8362 engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8363 calls: calls.clone(),
8364 }));
8365 engine.start_gasp_sync().await.unwrap();
8366 assert_eq!(
8367 calls
8368 .borrow()
8369 .iter()
8370 .map(|(_, topic)| topic.as_str())
8371 .collect::<Vec<_>>(),
8372 vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
8373 );
8374 assert_eq!(lookups.borrow().len(), 2);
8375 }
8376
8377 #[tokio::test]
8378 async fn test_start_gasp_sync_ship_discovers_peers() {
8379 let broadcaster = MockBroadcaster::new();
8380 let (engine, _calls) = make_engine_with_broadcaster(
8381 vec![0],
8382 broadcaster,
8383 vec![
8384 ("https://peer1.example.com", "tm_test"),
8385 ("https://peer2.example.com", "tm_test"),
8386 ],
8387 Some("https://self.example.com"),
8388 )
8389 .await;
8390
8391 let result = engine.start_gasp_sync().await.unwrap();
8392 assert!(
8393 result.topics_synced.contains_key("tm_test"),
8394 "tm_test should be in sync results"
8395 );
8396
8397 let topic_result = &result.topics_synced["tm_test"];
8398 assert_eq!(topic_result.sync_type, "ship");
8399 assert_eq!(
8400 topic_result.peers.len(),
8401 2,
8402 "Should discover both SHIP peers"
8403 );
8404 assert!(topic_result
8405 .peers
8406 .contains(&"https://peer1.example.com".to_string()));
8407 assert!(topic_result
8408 .peers
8409 .contains(&"https://peer2.example.com".to_string()));
8410 }
8411
8412 #[tokio::test]
8413 async fn test_start_gasp_sync_ship_filters_self() {
8414 let broadcaster = MockBroadcaster::new();
8415 let (engine, _calls) = make_engine_with_broadcaster(
8416 vec![0],
8417 broadcaster,
8418 vec![
8419 ("https://self.example.com", "tm_test"),
8420 ("https://peer2.example.com", "tm_test"),
8421 ],
8422 Some("https://self.example.com"),
8423 )
8424 .await;
8425
8426 let result = engine.start_gasp_sync().await.unwrap();
8427 let topic_result = &result.topics_synced["tm_test"];
8428 assert_eq!(
8429 topic_result.peers.len(),
8430 1,
8431 "Self URL should be filtered out from SHIP discovery"
8432 );
8433 assert_eq!(topic_result.peers[0], "https://peer2.example.com");
8434 }
8435
8436 #[tokio::test]
8437 async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
8438 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8439 managers.insert(
8440 "tm_custom".to_string(),
8441 Box::new(MockTopicManager::admitting(vec![])),
8442 );
8443
8444 let mut sync_config: SyncConfiguration = HashMap::new();
8445 sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
8446
8447 let engine = Engine::new(
8448 managers,
8449 HashMap::new(),
8450 Box::new(MemoryStorage::new()),
8451 None,
8452 EngineConfig {
8453 sync_configuration: sync_config,
8454 ..Default::default()
8455 },
8456 );
8457
8458 let result = engine.start_gasp_sync().await.unwrap();
8459 let topic_result = &result.topics_synced["tm_custom"];
8460 assert!(
8461 topic_result.peers.is_empty(),
8462 "Without ls_ship, SHIP discovery should return no peers"
8463 );
8464 }
8465
8466 #[tokio::test]
8467 async fn test_start_gasp_sync_mixed_config() {
8468 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8469 managers.insert(
8470 "tm_peers".to_string(),
8471 Box::new(MockTopicManager::admitting(vec![])),
8472 );
8473 managers.insert(
8474 "tm_disabled".to_string(),
8475 Box::new(MockTopicManager::admitting(vec![])),
8476 );
8477 managers.insert(
8478 "tm_ship_topic".to_string(),
8479 Box::new(MockTopicManager::admitting(vec![])),
8480 );
8481
8482 let mut sync_config: SyncConfiguration = HashMap::new();
8483 sync_config.insert(
8484 "tm_peers".to_string(),
8485 SyncTarget::Peers(vec!["https://peer.com".to_string()]),
8486 );
8487 sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
8488 sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
8489
8490 let engine = Engine::new(
8491 managers,
8492 HashMap::new(),
8493 Box::new(MemoryStorage::new()),
8494 None,
8495 EngineConfig {
8496 sync_configuration: sync_config,
8497 ..Default::default()
8498 },
8499 );
8500
8501 let result = engine.start_gasp_sync().await.unwrap();
8502 assert_eq!(result.topics_synced.len(), 2);
8503 assert!(result.topics_synced.contains_key("tm_peers"));
8504 assert!(result.topics_synced.contains_key("tm_ship_topic"));
8505 assert!(!result.topics_synced.contains_key("tm_disabled"));
8506
8507 assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
8508 assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
8509 assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
8510 }
8511
8512 #[tokio::test]
8513 async fn test_gasp_sync_result_serialization() {
8514 let mut topics_synced = HashMap::new();
8515 topics_synced.insert(
8516 "tm_test".to_string(),
8517 super::TopicSyncResult {
8518 roots_proven: 0,
8519 roots_unproven: 0,
8520 cursor_held_by: Vec::new(),
8521 expired_refused: 0,
8522 peers: vec!["https://peer.com".to_string()],
8523 sync_type: "ship".to_string(),
8524 errors: vec![],
8525 pruned_inputs: 0,
8526 discarded_graphs: 0,
8527 anchor_walk_held_graphs: 0,
8528 finalized_graphs: 0,
8529 deadline_dropped_graphs: 0,
8530 cursor_moves: Vec::new(),
8531 deferred_graphs: 0,
8532 resumed_graphs: 0,
8533 converged_graphs: 0,
8534 dropped_graphs: Vec::new(),
8535 stalled_graphs: 0,
8536 held_back_graphs: 0,
8537 unanchorable_graphs: 0,
8538 unanchorable_cleared: 0,
8539 faulted_roots: Vec::new(),
8540 },
8541 );
8542
8543 let result = super::GASPSyncResult { topics_synced };
8544 let json = serde_json::to_string(&result).unwrap();
8545 let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
8546
8547 assert_eq!(back.topics_synced.len(), 1);
8548 assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
8549 assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
8550 }
8551
8552 struct MockSyncRemote {
8556 utxos: Vec<crate::types::GASPOutput>,
8557 }
8558
8559 #[async_trait(?Send)]
8560 impl crate::gasp::GASPRemote for MockSyncRemote {
8561 async fn get_initial_response(
8562 &self,
8563 request: &crate::types::GASPInitialRequest,
8564 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
8565 let utxos: Vec<crate::types::GASPOutput> = self
8566 .utxos
8567 .iter()
8568 .filter(|u| u.score as u64 >= request.since)
8569 .cloned()
8570 .collect();
8571 Ok(crate::types::GASPInitialResponse {
8572 utxo_list: utxos,
8573 since: request.since,
8574 })
8575 }
8576 async fn get_initial_reply(
8577 &self,
8578 _: &crate::types::GASPInitialResponse,
8579 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
8580 Ok(crate::types::GASPInitialReply {
8581 utxo_list: Vec::new(),
8582 })
8583 }
8584 async fn request_node(
8585 &self,
8586 graph_id: &str,
8587 txid: &str,
8588 output_index: u32,
8589 _: bool,
8590 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
8591 Ok(crate::types::GASPNode {
8592 graph_id: graph_id.to_string(),
8593 raw_tx: format!("rawtx_{txid}"),
8594 output_index,
8595 proof: None,
8596 tx_metadata: None,
8597 output_metadata: None,
8598 inputs: None,
8599 })
8600 }
8601 async fn submit_node(
8602 &self,
8603 _: &crate::types::GASPNode,
8604 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
8605 Ok(None)
8606 }
8607 }
8608
8609 struct MockGASPRemoteFactory;
8611
8612 #[derive(Clone)]
8613 struct BudgetPageRemote {
8614 outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
8615 nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
8616 elapsed: std::rc::Rc<Cell<u64>>,
8617 requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
8618 clamp: Option<usize>,
8619 node_cost_ms: u64,
8620 slow_once: std::rc::Rc<std::cell::RefCell<Option<(String, u64)>>>,
8622 asked: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
8624 }
8625
8626 #[async_trait(?Send)]
8627 impl crate::gasp::GASPRemote for BudgetPageRemote {
8628 async fn get_initial_response(
8629 &self,
8630 request: &crate::types::GASPInitialRequest,
8631 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
8632 self.requests.borrow_mut().push(request.clone());
8633 let page = self
8634 .outputs
8635 .iter()
8636 .filter(|u| u.score as u64 >= request.since);
8637 let limit = self.clamp.map_or(usize::MAX, |cap| {
8638 cap.min(request.limit.unwrap_or(u64::MAX) as usize)
8639 });
8640 Ok(crate::types::GASPInitialResponse {
8641 utxo_list: page.take(limit).cloned().collect(),
8642 since: request.since,
8643 })
8644 }
8645
8646 async fn get_initial_reply(
8647 &self,
8648 _: &crate::types::GASPInitialResponse,
8649 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
8650 unreachable!()
8651 }
8652
8653 async fn request_node(
8654 &self,
8655 graph_id: &str,
8656 txid: &str,
8657 _: u32,
8658 _: bool,
8659 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
8660 self.asked.borrow_mut().push(txid.to_string());
8661 self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
8662 let slow = {
8663 let mut slow = self.slow_once.borrow_mut();
8664 match slow.as_ref() {
8665 Some((t, _)) if t == txid => slow.take().map(|(_, ms)| ms),
8666 _ => None,
8667 }
8668 };
8669 if let Some(ms) = slow {
8670 self.elapsed.set(self.elapsed.get() + ms);
8671 }
8672 if self.node_cost_ms > 0 {
8673 let mut yielded = false;
8674 std::future::poll_fn(|cx| {
8675 if yielded {
8676 std::task::Poll::Ready(())
8677 } else {
8678 yielded = true;
8679 cx.waker().wake_by_ref();
8680 std::task::Poll::Pending
8681 }
8682 })
8683 .await;
8684 }
8685 let mut node = self
8686 .nodes
8687 .get(txid)
8688 .cloned()
8689 .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
8690 node.graph_id = graph_id.to_string();
8691 Ok(node)
8692 }
8693
8694 async fn submit_node(
8695 &self,
8696 _: &crate::types::GASPNode,
8697 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
8698 unreachable!()
8699 }
8700 }
8701
8702 impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
8703 fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
8704 Box::new(self.clone())
8705 }
8706 }
8707
8708 fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
8709 let mut engine = make_engine(vec![0]);
8710 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
8711 let mut outputs = Vec::new();
8712 let mut nodes = HashMap::new();
8713 for n in 1..=count {
8714 let tx = child_of(&format!("{n:064x}"), 0);
8715 let txid = tx.id();
8716 tracker.add_root(n, txid.clone());
8717 outputs.push(crate::types::GASPOutput {
8718 txid: txid.clone(),
8719 output_index: 0,
8720 score: f64::from(n * 10),
8721 });
8722 nodes.insert(
8723 txid.clone(),
8724 crate::types::GASPNode {
8725 graph_id: String::new(),
8726 raw_tx: tx.to_hex(),
8727 output_index: 0,
8728 proof: Some(single_leaf_bump_hex(&txid, n)),
8729 tx_metadata: None,
8730 output_metadata: None,
8731 inputs: None,
8732 },
8733 );
8734 }
8735 engine.chain_tracker = Some(Box::new(tracker));
8736 engine.config.sync_configuration.insert(
8737 "tm_test".to_string(),
8738 SyncTarget::Peers(vec!["https://budget.example".to_string()]),
8739 );
8740 let remote = BudgetPageRemote {
8741 outputs: std::rc::Rc::new(outputs),
8742 nodes: std::rc::Rc::new(nodes),
8743 elapsed: std::rc::Rc::new(Cell::new(0)),
8744 requests: std::rc::Rc::default(),
8745 clamp: None,
8746 node_cost_ms: 5000,
8747 slow_once: std::rc::Rc::default(),
8748 asked: std::rc::Rc::default(),
8749 };
8750 (engine, remote)
8751 }
8752
8753 fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
8754 let elapsed = remote.elapsed.clone();
8755 engine.set_peer_sync_budget(
8756 std::rc::Rc::new(move |ms| {
8757 assert_eq!(ms, 30_000);
8758 let elapsed = elapsed.clone();
8759 let until = elapsed.get() + ms;
8760 Box::pin(std::future::poll_fn(move |_| {
8761 if elapsed.get() >= until {
8762 std::task::Poll::Ready(())
8763 } else {
8764 std::task::Poll::Pending
8765 }
8766 }))
8767 }),
8768 30_000,
8769 );
8770 }
8771
8772 #[tokio::test]
8773 async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
8774 let (mut engine, remote) = budget_page_engine(3000);
8777 virtual_peer_budget(&mut engine, &remote);
8778 engine.set_gasp_remote_factory(Box::new(remote.clone()));
8779 for round in 1..=2 {
8780 let result = engine.start_gasp_sync().await.unwrap();
8781 let topic = &result.topics_synced["tm_test"];
8782 assert_eq!(topic.finalized_graphs, 5);
8783 assert_eq!(topic.deadline_dropped_graphs, 1);
8784 assert_eq!(topic.cursor_moves.len(), 1);
8785 let expected = round * 50;
8786 assert_eq!(topic.cursor_moves[0].to, expected);
8787 assert_eq!(
8788 engine
8789 .storage
8790 .get_last_interaction("https://budget.example", "tm_test")
8791 .await
8792 .unwrap(),
8793 expected
8794 );
8795 {
8796 let requests = remote.requests.borrow();
8797 assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
8798 }
8799 for output in remote.outputs.iter().take((round * 5) as usize) {
8800 assert!(engine
8801 .storage
8802 .find_output(&output.txid, 0, Some("tm_test"), None, true)
8803 .await
8804 .unwrap()
8805 .is_some());
8806 }
8807 let unfinished = &remote.outputs[(round * 5) as usize];
8808 assert!(engine
8809 .storage
8810 .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
8811 .await
8812 .unwrap()
8813 .is_none());
8814 }
8815 }
8816
8817 #[tokio::test]
8818 async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
8819 let (mut engine, mut remote) = budget_page_engine(7);
8820 remote.clamp = Some(3);
8821 remote.node_cost_ms = 0;
8822 virtual_peer_budget(&mut engine, &remote);
8823 engine.set_gasp_remote_factory(Box::new(remote.clone()));
8824 let result = engine.start_gasp_sync().await.unwrap();
8825 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
8826 let requests = remote.requests.borrow();
8827 assert_eq!(requests.len(), 4);
8828 assert!(requests.iter().all(|request| request.limit == Some(100)));
8829 assert_eq!(
8830 requests.iter().map(|r| r.since).collect::<Vec<_>>(),
8831 vec![0, 30, 50, 70]
8832 );
8833 }
8834
8835 #[tokio::test]
8836 async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
8837 let (mut engine, mut remote) = budget_page_engine(3000);
8838 std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
8839 virtual_peer_budget(&mut engine, &remote);
8840 engine.set_gasp_remote_factory(Box::new(remote));
8841 let result = engine.start_gasp_sync().await.unwrap();
8842 let topic = &result.topics_synced["tm_test"];
8843 assert_eq!(topic.finalized_graphs, 5);
8844 assert!(topic.cursor_moves.is_empty());
8847 }
8848
8849 #[tokio::test]
8850 async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
8851 let (mut engine, mut remote) = budget_page_engine(3);
8852 std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
8853 remote.node_cost_ms = 10_000;
8854 virtual_peer_budget(&mut engine, &remote);
8855 engine.set_gasp_remote_factory(Box::new(remote));
8856 let result = engine.start_gasp_sync().await.unwrap();
8857 let topic = &result.topics_synced["tm_test"];
8858 assert_eq!(topic.finalized_graphs, 2);
8859 assert_eq!(topic.cursor_moves[0].to, 10);
8860 }
8861
8862 #[tokio::test]
8863 async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
8864 let (mut engine, mut remote) = budget_page_engine(3000);
8865 let failed = remote.outputs[2].txid.clone();
8866 std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
8867 virtual_peer_budget(&mut engine, &remote);
8868 engine.set_gasp_remote_factory(Box::new(remote));
8869 let result = engine.start_gasp_sync().await.unwrap();
8870 let topic = &result.topics_synced["tm_test"];
8871 assert_eq!(topic.finalized_graphs, 4);
8872 assert_eq!(topic.cursor_moves[0].to, 20);
8873 }
8874
8875 struct ProofOnlyRootFetcher {
8876 proofs: HashMap<String, String>,
8877 requested: std::rc::Rc<Cell<u64>>,
8878 }
8879
8880 #[async_trait(?Send)]
8881 impl AncestorFetcher for ProofOnlyRootFetcher {
8882 async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
8883 assert!(
8884 self.requested.get() > 0,
8885 "root proof request must precede ancestry"
8886 );
8887 Err(GASPError::RemoteError("raw source unavailable".to_string()))
8888 }
8889 async fn verified_proof_for(&self, txid: &str) -> Option<String> {
8890 self.requested.set(self.requested.get() + 1);
8891 self.proofs.get(txid).cloned()
8892 }
8893 }
8894
8895 #[tokio::test]
8896 async fn copy_sync_leaves_completion_courier_untouched() {
8897 let (mut engine, mut remote) = budget_page_engine(1);
8898 remote.node_cost_ms = 0;
8899 let txid = remote.outputs[0].txid.clone();
8900 let proof = std::rc::Rc::make_mut(&mut remote.nodes)
8901 .get_mut(&txid)
8902 .unwrap()
8903 .proof
8904 .take()
8905 .unwrap();
8906 let completion_asks = std::rc::Rc::new(Cell::new(0));
8907 let sync_asks = std::rc::Rc::new(Cell::new(0));
8908 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8909 proofs: HashMap::new(),
8910 requested: completion_asks.clone(),
8911 }));
8912 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8913 proofs: HashMap::from([(txid.clone(), proof.clone())]),
8914 requested: sync_asks.clone(),
8915 }));
8916 engine.set_gasp_remote_factory(Box::new(remote));
8917 let result = engine.start_gasp_sync().await.unwrap();
8918 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
8919 assert_eq!(result.topics_synced["tm_test"].roots_proven, 1);
8920 assert_eq!(result.topics_synced["tm_test"].roots_unproven, 0);
8921 let stored = engine
8922 .storage()
8923 .find_output(&txid, 0, Some("tm_test"), None, true)
8924 .await
8925 .unwrap()
8926 .unwrap();
8927 assert!(
8928 crate::beef_limits::own_proof(stored.beef.as_ref().unwrap(), &txid)
8929 .unwrap()
8930 .is_some()
8931 );
8932 assert_eq!(completion_asks.get(), 0);
8933 assert_eq!(sync_asks.get(), 1);
8934 let served = engine
8935 .provide_foreign_gasp_node(&format!("{txid}.0"), &txid, 0)
8936 .await
8937 .unwrap();
8938 assert_eq!(served.proof, Some(proof));
8939 }
8940
8941 #[tokio::test]
8942 async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
8943 for proven in [true, false] {
8944 let (mut engine, mut remote) = budget_page_engine(1);
8945 let txid = remote.outputs[0].txid.clone();
8946 let proof = remote.nodes[&txid].proof.clone().unwrap();
8947 std::rc::Rc::make_mut(&mut remote.nodes)
8948 .get_mut(&txid)
8949 .unwrap()
8950 .proof = None;
8951 let requested = std::rc::Rc::new(Cell::new(0));
8952 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8953 proofs: if proven {
8954 HashMap::from([(txid.clone(), proof)])
8955 } else {
8956 HashMap::new()
8957 },
8958 requested: requested.clone(),
8959 }));
8960 virtual_peer_budget(&mut engine, &remote);
8961 engine.set_gasp_remote_factory(Box::new(remote));
8962 let result = engine.start_gasp_sync().await.unwrap();
8963 assert!(requested.get() > 0);
8964 assert_eq!(
8965 result.topics_synced["tm_test"].finalized_graphs,
8966 u64::from(proven)
8967 );
8968 assert_eq!(
8969 engine
8970 .storage
8971 .find_output(&txid, 0, Some("tm_test"), None, true)
8972 .await
8973 .unwrap()
8974 .is_some(),
8975 proven
8976 );
8977 if !proven {
8978 assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
8979 }
8980 }
8981 }
8982
8983 #[tokio::test]
8984 async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
8985 let (mut engine, mut remote) = budget_page_engine(1);
8986 let txid = remote.outputs[0].txid.clone();
8987 let proof = remote.nodes[&txid].proof.clone().unwrap();
8988 std::rc::Rc::make_mut(&mut remote.nodes)
8989 .get_mut(&txid)
8990 .unwrap()
8991 .proof = None;
8992 let requested = std::rc::Rc::new(Cell::new(0));
8993 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
8994 proofs: HashMap::new(),
8995 requested: requested.clone(),
8996 }));
8997 virtual_peer_budget(&mut engine, &remote);
8998 engine.set_graph_budget(
8999 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9000 5,
9001 5000,
9002 );
9003 engine.set_gasp_remote_factory(Box::new(remote));
9004 let held = engine.start_gasp_sync().await.unwrap();
9005 assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
9006 assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
9007 assert_eq!(held.topics_synced["tm_test"].cursor_moves[0].to, 10);
9008 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9009 proofs: HashMap::from([(txid.clone(), proof)]),
9010 requested,
9011 }));
9012 engine
9013 .storage()
9014 .update_last_interaction("https://budget.example", "tm_test", 20)
9015 .await
9016 .unwrap();
9017 let landed = engine.start_gasp_sync().await.unwrap();
9018 assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
9019 assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
9020 assert!(landed.topics_synced["tm_test"].cursor_moves.is_empty());
9021 assert!(engine
9022 .storage
9023 .find_output(&txid, 0, Some("tm_test"), None, true)
9024 .await
9025 .unwrap()
9026 .is_some());
9027 }
9028
9029 #[tokio::test]
9030 async fn proofs_deadline_stalls_become_durable_after_three_runs() {
9031 let (mut engine, remote) = budget_page_engine(1);
9032 let elapsed = remote.elapsed.clone();
9033 let root = format!("{}.0", remote.outputs[0].txid);
9034 engine.set_graph_budget(
9035 std::rc::Rc::new(move |ms| {
9036 let until = elapsed.get() + ms;
9037 let elapsed = elapsed.clone();
9038 Box::pin(std::future::poll_fn(move |_| {
9039 if elapsed.get() >= until {
9040 std::task::Poll::Ready(())
9041 } else {
9042 std::task::Poll::Pending
9043 }
9044 }))
9045 }),
9046 10,
9047 1_000,
9048 );
9049 engine.set_gasp_remote_factory(Box::new(remote));
9050 for round in 1..=crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9051 let result = engine.start_gasp_sync().await.unwrap();
9052 let topic = &result.topics_synced["tm_test"];
9053 if round < crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9054 assert!(topic.cursor_moves.is_empty());
9055 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
9056 } else {
9057 assert_eq!(topic.cursor_moves[0].to, 10);
9058 assert_eq!(topic.deferred_graphs, 1);
9059 }
9060 }
9061 let held = engine
9062 .storage()
9063 .get_deferred_graph("https://budget.example", "tm_test", &root)
9064 .await
9065 .unwrap()
9066 .unwrap();
9067 assert!(held.cursor_passable);
9068 assert!(held.nodes.is_empty());
9069 }
9070
9071 #[tokio::test]
9077 async fn cursor_018_the_graph_in_flight_at_the_peer_deadline_is_held_and_passed() {
9078 let (mut engine, remote) = budget_page_engine(6);
9079 let slow = remote.outputs[2].txid.clone();
9080 let root = format!("{slow}.0");
9081 *remote.slow_once.borrow_mut() = Some((slow.clone(), 60_000));
9082 virtual_peer_budget(&mut engine, &remote);
9083 engine.set_graph_budget(
9084 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9085 100,
9086 60_000,
9087 );
9088 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9089 let result = engine.start_gasp_sync().await.unwrap();
9090 let topic = &result.topics_synced["tm_test"];
9091 assert_eq!(topic.finalized_graphs, 2);
9092 assert_eq!(topic.deadline_dropped_graphs, 1);
9093 assert_eq!(topic.deferred_graphs, 1);
9094 assert_eq!(topic.cursor_moves.len(), 1);
9095 assert_eq!(topic.cursor_moves[0].to, 30, "past the held graph");
9096 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
9097 let held = engine
9098 .storage()
9099 .get_deferred_graph("https://budget.example", "tm_test", &root)
9100 .await
9101 .unwrap()
9102 .unwrap();
9103 assert!(held.cursor_passable);
9104 assert_eq!(held.reason, "peer_deadline");
9105 assert!(held.nodes.is_empty());
9106 assert_eq!(held.score, 30);
9107
9108 let result = engine.start_gasp_sync().await.unwrap();
9109 let topic = &result.topics_synced["tm_test"];
9110 assert_eq!(topic.resumed_graphs, 1);
9111 assert_eq!(topic.converged_graphs, 1);
9112 assert_eq!(topic.finalized_graphs, 4, "the held graph and 40, 50, 60");
9113 assert_eq!(topic.cursor_moves[0].to, 60);
9114 assert!(engine
9115 .storage()
9116 .get_deferred_graph("https://budget.example", "tm_test", &root)
9117 .await
9118 .unwrap()
9119 .is_none());
9120 for output in remote.outputs.iter() {
9121 assert!(engine
9122 .storage
9123 .find_output(&output.txid, 0, Some("tm_test"), None, true)
9124 .await
9125 .unwrap()
9126 .is_some());
9127 }
9128 }
9129
9130 struct ExpiredRoots(MockTopicManager);
9132
9133 #[async_trait(?Send)]
9134 impl TopicManagerTrait for ExpiredRoots {
9135 async fn identify_admissible_outputs(
9136 &self,
9137 tx: &Transaction,
9138 previous_coins: &[u8],
9139 off_chain_values: Option<&[u8]>,
9140 mode: SubmitMode,
9141 context: &TopicAdmittanceContext,
9142 ) -> Result<AdmittanceInstructions, TopicManagerError> {
9143 self.0
9144 .identify_admissible_outputs(tx, previous_coins, off_chain_values, mode, context)
9145 .await
9146 }
9147 async fn get_documentation(&self) -> String {
9148 self.0.get_documentation().await
9149 }
9150 async fn get_metadata(&self) -> ServiceMetadata {
9151 self.0.get_metadata().await
9152 }
9153 fn sync_expiry(&self, _: &bsv_rs::script::LockingScript) -> Option<u64> {
9154 Some(1)
9155 }
9156 }
9157
9158 fn dead_page_engine(count: u32) -> (Engine, BudgetPageRemote, std::rc::Rc<MemoryStorage>) {
9161 let (mut engine, mut remote) = budget_page_engine(count);
9162 let gap = remote.outputs[0].txid.clone();
9163 std::rc::Rc::make_mut(&mut remote.nodes).remove(&gap);
9164 engine.managers.insert(
9165 "tm_test".to_string(),
9166 Box::new(ExpiredRoots(MockTopicManager::admitting(vec![0]))),
9167 );
9168 let store = std::rc::Rc::new(MemoryStorage::new());
9169 engine.storage = Box::new(store.clone());
9170 engine.set_graph_budget(
9171 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9172 100,
9173 60_000,
9174 );
9175 (engine, remote, store)
9176 }
9177
9178 #[tokio::test]
9182 async fn cursor_018_a_sync_that_only_refuses_the_dead_is_a_yield() {
9183 let (mut engine, mut remote, store) = dead_page_engine(3);
9184 remote.node_cost_ms = 0;
9185 virtual_peer_budget(&mut engine, &remote);
9186 engine.set_gasp_remote_factory(Box::new(remote));
9187 let result = engine.start_gasp_sync().await.unwrap();
9188 let topic = &result.topics_synced["tm_test"];
9189 assert_eq!(topic.expired_refused, 2);
9190 assert_eq!(topic.finalized_graphs, 0);
9191 assert!(topic.cursor_moves.is_empty());
9192 let host = crate::gasp::peer_origin("https://budget.example");
9193 assert_eq!(store.peer_yieldless_syncs(&host, "tm_test"), 0);
9194 let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9195 assert_eq!(health.consecutive_failures, 0);
9196 }
9197
9198 #[tokio::test]
9204 async fn cursor_018_deadline_drops_in_a_row_do_not_quarantine_a_serving_peer() {
9205 let (mut engine, remote, store) = dead_page_engine(200);
9206 virtual_peer_budget(&mut engine, &remote);
9207 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9208 let host = crate::gasp::peer_origin("https://budget.example");
9209 let runs = crate::gasp::PEER_QUARANTINE_THRESHOLD + 1;
9210 for run in 1..=runs {
9211 let asked = remote.asked.borrow().len();
9212 let result = engine.start_gasp_sync().await.unwrap();
9213 let topic = &result.topics_synced["tm_test"];
9214 assert!(remote.asked.borrow().len() > asked, "run {run}: attempted");
9215 assert_eq!(topic.deadline_dropped_graphs, 1, "run {run}");
9216 assert!(topic.expired_refused > 0, "run {run}: {topic:?}");
9217 if run < u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9221 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
9222 } else if run == u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9223 assert_eq!(topic.unanchorable_graphs, 1, "run {run}: {topic:?}");
9224 assert!(!topic.cursor_moves.is_empty(), "run {run}: passed");
9225 }
9226 let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9227 assert_eq!(health.consecutive_failures, 0, "run {run}");
9228 }
9229 let asked = remote.asked.borrow();
9233 let mut dead: Vec<&String> = asked
9234 .iter()
9235 .filter(|t| **t != remote.outputs[0].txid)
9236 .collect();
9237 let fetched = dead.len();
9238 dead.sort();
9239 dead.dedup();
9240 assert_eq!(
9241 fetched - dead.len(),
9242 (runs - 1) as usize,
9243 "one per resumed cut"
9244 );
9245 assert_eq!(store.expired_outpoints("tm_test").len(), dead.len() - 1);
9246 }
9247
9248 struct GatedCourier {
9251 txid: String,
9252 raw_tx: String,
9253 proof: String,
9254 serve: std::rc::Rc<Cell<bool>>,
9255 asked: std::rc::Rc<Cell<u64>>,
9256 }
9257
9258 #[async_trait(?Send)]
9259 impl AncestorFetcher for GatedCourier {
9260 async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
9261 self.asked.set(self.asked.get() + 1);
9262 if self.serve.get() && txid == self.txid {
9263 Ok(FetchedAncestor {
9264 raw_tx: self.raw_tx.clone(),
9265 proof: Some(self.proof.clone()),
9266 })
9267 } else {
9268 Err(GASPError::RemoteError(format!(
9269 "no rung served the raw of {txid}"
9270 )))
9271 }
9272 }
9273 async fn verified_proof_for(&self, _: &str) -> Option<String> {
9274 None
9275 }
9276 }
9277
9278 struct UnanchorableFixture {
9282 engine: Engine,
9283 remote: BudgetPageRemote,
9284 store: std::rc::Rc<MemoryStorage>,
9285 root: String,
9286 serve: std::rc::Rc<Cell<bool>>,
9287 asked: std::rc::Rc<Cell<u64>>,
9288 }
9289
9290 fn unanchorable_fixture() -> UnanchorableFixture {
9291 let (mut engine, mut remote) = budget_page_engine(3);
9292 remote.node_cost_ms = 0;
9293 let parent = child_of(&format!("{:064x}", 0xab), 0);
9294 let child = child_of(&parent.id(), 0);
9295 let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
9296 for n in 1..=3u32 {
9297 tracker.add_root(n, child_of(&format!("{n:064x}"), 0).id());
9298 }
9299 tracker.add_root(900, parent.id());
9300 engine.chain_tracker = Some(Box::new(tracker));
9301 let replaced = std::mem::replace(
9302 &mut std::rc::Rc::make_mut(&mut remote.outputs)[0].txid,
9303 child.id(),
9304 );
9305 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
9306 nodes.remove(&replaced);
9307 nodes.insert(
9308 child.id(),
9309 crate::types::GASPNode {
9310 graph_id: String::new(),
9311 raw_tx: child.to_hex(),
9312 output_index: 0,
9313 proof: None,
9314 tx_metadata: None,
9315 output_metadata: None,
9316 inputs: None,
9317 },
9318 );
9319 let serve = std::rc::Rc::new(Cell::new(false));
9320 let asked = std::rc::Rc::new(Cell::new(0));
9321 engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(GatedCourier {
9322 txid: parent.id(),
9323 raw_tx: parent.to_hex(),
9324 proof: single_leaf_bump_hex(&parent.id(), 900),
9325 serve: serve.clone(),
9326 asked: asked.clone(),
9327 }));
9328 let store = std::rc::Rc::new(MemoryStorage::new());
9329 engine.storage = Box::new(store.clone());
9330 virtual_peer_budget(&mut engine, &remote);
9331 engine.set_graph_budget(
9332 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9333 100,
9334 60_000,
9335 );
9336 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9337 UnanchorableFixture {
9338 engine,
9339 remote,
9340 store,
9341 root: format!("{}.0", child.id()),
9342 serve,
9343 asked,
9344 }
9345 }
9346
9347 #[tokio::test]
9356 async fn unanchorable_019_a_root_faulted_three_runs_is_recorded_passed_and_admitted_later() {
9357 let UnanchorableFixture {
9358 engine,
9359 remote,
9360 store,
9361 root,
9362 serve,
9363 asked,
9364 } = unanchorable_fixture();
9365 let host = "https://budget.example";
9366 let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
9367 for run in 1..=runs {
9368 let result = engine.start_gasp_sync().await.unwrap();
9369 let topic = &result.topics_synced["tm_test"];
9370 assert_eq!(topic.faulted_roots.len(), 1, "run {run}: {topic:?}");
9371 let named = &topic.faulted_roots[0];
9372 assert_eq!(
9373 (named.peer.as_str(), named.outpoint.as_str()),
9374 (host, root.as_str())
9375 );
9376 assert_eq!(named.class, crate::gasp::fault_class::COURIER);
9377 assert_eq!(named.faults, run, "run {run}");
9378 assert!(named.error.contains("no rung served"), "{named:?}");
9379 let record = engine
9380 .storage()
9381 .get_deferred_graph(host, "tm_test", &root)
9382 .await
9383 .unwrap()
9384 .unwrap();
9385 assert_eq!(record.faults, run);
9386 assert!(record.nodes.is_empty(), "the root alone, asked again");
9387 if run < runs {
9388 assert!(!named.unanchorable);
9389 assert_eq!(topic.unanchorable_graphs, 0);
9390 assert_eq!(record.reason, "retry:fault:courier");
9391 assert!(!record.cursor_passable);
9392 assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
9393 assert_eq!(topic.cursor_held_by, vec![root.clone()]);
9394 } else {
9395 assert!(named.unanchorable);
9396 assert_eq!(topic.unanchorable_graphs, 1);
9397 assert_eq!(record.reason, "unanchorable:courier");
9398 assert!(record.cursor_passable);
9399 assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
9400 assert_eq!(topic.cursor_moves.len(), 1);
9401 assert_eq!(
9402 topic.cursor_moves[0].to, 30,
9403 "the page's last finished score"
9404 );
9405 assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
9406 }
9407 if run == 1 {
9408 assert_eq!(topic.finalized_graphs, 2, "20 and 30");
9409 }
9410 }
9411
9412 let (courier_asks, node_asks) = (asked.get(), remote.asked.borrow().len());
9415 let streak = store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test");
9416 let result = engine.start_gasp_sync().await.unwrap();
9417 let topic = &result.topics_synced["tm_test"];
9418 assert!(topic.faulted_roots.is_empty(), "{topic:?}");
9419 assert!(topic.cursor_held_by.is_empty());
9420 assert_eq!(topic.unanchorable_graphs, 0);
9421 assert_eq!(asked.get(), courier_asks);
9422 assert_eq!(remote.asked.borrow().len(), node_asks);
9423 assert_eq!(
9424 store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test"),
9425 streak
9426 );
9427
9428 let mut record = engine
9431 .storage()
9432 .get_deferred_graph(host, "tm_test", &root)
9433 .await
9434 .unwrap()
9435 .unwrap();
9436 record.retry_at = 1;
9437 engine.storage().put_deferred_graph(&record).await.unwrap();
9438 serve.set(true);
9439 let result = engine.start_gasp_sync().await.unwrap();
9440 let topic = &result.topics_synced["tm_test"];
9441 assert_eq!(topic.finalized_graphs, 1, "{topic:?}");
9442 assert_eq!(topic.unanchorable_cleared, 1);
9443 assert!(topic.faulted_roots.is_empty());
9444 assert!(engine
9445 .storage()
9446 .get_deferred_graph(host, "tm_test", &root)
9447 .await
9448 .unwrap()
9449 .is_none());
9450 let (txid, _) = crate::gasp::parse_outpoint(&root).unwrap();
9451 assert!(engine
9452 .storage
9453 .find_output(&txid, 0, Some("tm_test"), None, true)
9454 .await
9455 .unwrap()
9456 .is_some());
9457 }
9458
9459 #[tokio::test]
9464 async fn unanchorable_019_only_the_lowest_faulted_root_is_recorded_and_all_are_named() {
9465 let (mut engine, mut remote) = budget_page_engine(4);
9466 remote.node_cost_ms = 0;
9467 let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
9468 nodes.remove(&remote.outputs[1].txid);
9469 nodes.remove(&remote.outputs[3].txid);
9470 let store = std::rc::Rc::new(MemoryStorage::new());
9471 engine.storage = Box::new(store.clone());
9472 virtual_peer_budget(&mut engine, &remote);
9473 engine.set_graph_budget(
9474 std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9475 100,
9476 60_000,
9477 );
9478 engine.set_gasp_remote_factory(Box::new(remote.clone()));
9479 let result = engine.start_gasp_sync().await.unwrap();
9480 let topic = &result.topics_synced["tm_test"];
9481 let named: Vec<(&str, &str, u32)> = topic
9482 .faulted_roots
9483 .iter()
9484 .map(|f| (f.outpoint.as_str(), f.class.as_str(), f.faults))
9485 .collect();
9486 let low = format!("{}.0", remote.outputs[1].txid);
9487 let high = format!("{}.0", remote.outputs[3].txid);
9488 assert_eq!(
9489 named,
9490 vec![
9491 (low.as_str(), crate::gasp::fault_class::NOT_SERVED, 1),
9492 (high.as_str(), crate::gasp::fault_class::NOT_SERVED, 0),
9493 ]
9494 );
9495 assert_eq!(topic.cursor_moves[0].to, 10, "below the lower gap");
9496 let host = "https://budget.example";
9497 assert!(store
9498 .get_deferred_graph(host, "tm_test", &high)
9499 .await
9500 .unwrap()
9501 .is_none());
9502 assert_eq!(
9503 store
9504 .get_deferred_graph(host, "tm_test", &low)
9505 .await
9506 .unwrap()
9507 .unwrap()
9508 .reason,
9509 "retry:fault:not_served"
9510 );
9511 }
9512
9513 #[tokio::test]
9514 async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
9515 let (mut engine, mut remote) = budget_page_engine(1);
9516 let txid = remote.outputs[0].txid.clone();
9517 std::rc::Rc::make_mut(&mut remote.nodes)
9518 .get_mut(&txid)
9519 .unwrap()
9520 .proof = None;
9521 engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9522 proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
9523 requested: std::rc::Rc::default(),
9524 }));
9525 virtual_peer_budget(&mut engine, &remote);
9526 engine.set_gasp_remote_factory(Box::new(remote));
9527 let result = engine.start_gasp_sync().await.unwrap();
9528 assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
9529 assert!(engine
9530 .storage
9531 .find_output(&txid, 0, Some("tm_test"), None, true)
9532 .await
9533 .unwrap()
9534 .is_none());
9535 }
9536
9537 impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
9538 fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
9539 Box::new(MockSyncRemote {
9540 utxos: vec![
9541 crate::types::GASPOutput {
9542 txid: "remote_tx1".to_string(),
9543 output_index: 0,
9544 score: 100.0,
9545 },
9546 crate::types::GASPOutput {
9547 txid: "remote_tx2".to_string(),
9548 output_index: 0,
9549 score: 200.0,
9550 },
9551 ],
9552 })
9553 }
9554 }
9555
9556 #[tokio::test]
9557 async fn test_start_gasp_sync_with_factory_runs_sync() {
9558 let mut sync_config: SyncConfiguration = HashMap::new();
9559 sync_config.insert(
9560 "tm_test".to_string(),
9561 SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
9562 );
9563
9564 let mut engine = Engine::new(
9565 HashMap::new(),
9566 HashMap::new(),
9567 Box::new(MemoryStorage::new()),
9568 None,
9569 EngineConfig {
9570 sync_configuration: sync_config,
9571 ..Default::default()
9572 },
9573 );
9574
9575 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
9576
9577 let result = engine.start_gasp_sync().await.unwrap();
9578 assert_eq!(result.topics_synced.len(), 1);
9579
9580 let topic_result = &result.topics_synced["tm_test"];
9581 assert_eq!(topic_result.peers.len(), 1);
9582 assert!(
9583 topic_result.errors.is_empty(),
9584 "Sync should succeed with mock remote"
9585 );
9586
9587 let last = engine
9589 .storage()
9590 .get_last_interaction("https://peer1.example.com", "tm_test")
9591 .await
9592 .unwrap();
9593 assert_eq!(
9596 last, 0,
9597 "cursor must remain at entry when no output completed"
9598 );
9599 }
9600
9601 #[tokio::test]
9602 async fn test_start_gasp_sync_with_factory_handles_error() {
9603 struct FailingFactory;
9604
9605 impl crate::gasp::GASPRemoteFactory for FailingFactory {
9606 fn create_remote(
9607 &self,
9608 _peer_url: &str,
9609 _topic: &str,
9610 ) -> Box<dyn crate::gasp::GASPRemote> {
9611 struct FailingRemote;
9612 #[async_trait(?Send)]
9613 impl crate::gasp::GASPRemote for FailingRemote {
9614 async fn get_initial_response(
9615 &self,
9616 _: &crate::types::GASPInitialRequest,
9617 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
9618 {
9619 Err(crate::gasp::GASPError::RemoteError(
9620 "connection refused".into(),
9621 ))
9622 }
9623 async fn get_initial_reply(
9624 &self,
9625 _: &crate::types::GASPInitialResponse,
9626 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
9627 {
9628 unreachable!()
9629 }
9630 async fn request_node(
9631 &self,
9632 _: &str,
9633 _: &str,
9634 _: u32,
9635 _: bool,
9636 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
9637 {
9638 unreachable!()
9639 }
9640 async fn submit_node(
9641 &self,
9642 _: &crate::types::GASPNode,
9643 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
9644 {
9645 unreachable!()
9646 }
9647 }
9648 Box::new(FailingRemote)
9649 }
9650 }
9651
9652 let mut sync_config: SyncConfiguration = HashMap::new();
9653 sync_config.insert(
9654 "tm_test".to_string(),
9655 SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
9656 );
9657
9658 let mut engine = Engine::new(
9659 HashMap::new(),
9660 HashMap::new(),
9661 Box::new(MemoryStorage::new()),
9662 None,
9663 EngineConfig {
9664 sync_configuration: sync_config,
9665 ..Default::default()
9666 },
9667 );
9668
9669 engine.set_gasp_remote_factory(Box::new(FailingFactory));
9670
9671 let result = engine.start_gasp_sync().await.unwrap();
9672 assert_eq!(result.topics_synced.len(), 1);
9673
9674 let topic_result = &result.topics_synced["tm_test"];
9675 assert_eq!(
9676 topic_result.errors.len(),
9677 1,
9678 "Should have one error for the failing peer"
9679 );
9680 assert!(topic_result.errors[0].contains("connection refused"));
9681 }
9682
9683 #[tokio::test]
9684 async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
9685 let mut sync_config: SyncConfiguration = HashMap::new();
9686 sync_config.insert(
9687 "tm_test".to_string(),
9688 SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
9689 );
9690
9691 let engine = Engine::new(
9693 HashMap::new(),
9694 HashMap::new(),
9695 Box::new(MemoryStorage::new()),
9696 None,
9697 EngineConfig {
9698 sync_configuration: sync_config,
9699 ..Default::default()
9700 },
9701 );
9702
9703 let result = engine.start_gasp_sync().await.unwrap();
9704 assert_eq!(result.topics_synced.len(), 1);
9705
9706 let topic_result = &result.topics_synced["tm_test"];
9707 assert_eq!(topic_result.peers.len(), 1);
9708 assert!(topic_result.errors.is_empty());
9709 }
9710
9711 #[tokio::test]
9712 async fn test_start_gasp_sync_with_factory_multiple_peers() {
9713 let mut sync_config: SyncConfiguration = HashMap::new();
9714 sync_config.insert(
9715 "tm_test".to_string(),
9716 SyncTarget::Peers(vec![
9717 "https://peer1.example.com".to_string(),
9718 "https://peer2.example.com".to_string(),
9719 ]),
9720 );
9721
9722 let mut engine = Engine::new(
9723 HashMap::new(),
9724 HashMap::new(),
9725 Box::new(MemoryStorage::new()),
9726 None,
9727 EngineConfig {
9728 sync_configuration: sync_config,
9729 ..Default::default()
9730 },
9731 );
9732
9733 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
9734
9735 let result = engine.start_gasp_sync().await.unwrap();
9736 let topic_result = &result.topics_synced["tm_test"];
9737 assert_eq!(topic_result.peers.len(), 2);
9738 assert!(topic_result.errors.is_empty());
9739
9740 let last1 = engine
9742 .storage()
9743 .get_last_interaction("https://peer1.example.com", "tm_test")
9744 .await
9745 .unwrap();
9746 let last2 = engine
9747 .storage()
9748 .get_last_interaction("https://peer2.example.com", "tm_test")
9749 .await
9750 .unwrap();
9751 assert_eq!(last1, 0);
9752 assert_eq!(last2, 0);
9753 }
9754
9755 struct HangingRemote;
9761
9762 #[async_trait(?Send)]
9763 impl crate::gasp::GASPRemote for HangingRemote {
9764 async fn get_initial_response(
9765 &self,
9766 _: &crate::types::GASPInitialRequest,
9767 ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9768 std::future::pending::<()>().await;
9769 unreachable!()
9770 }
9771 async fn get_initial_reply(
9772 &self,
9773 _: &crate::types::GASPInitialResponse,
9774 ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9775 unreachable!()
9776 }
9777 async fn request_node(
9778 &self,
9779 _: &str,
9780 _: &str,
9781 _: u32,
9782 _: bool,
9783 ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9784 unreachable!()
9785 }
9786 async fn submit_node(
9787 &self,
9788 _: &crate::types::GASPNode,
9789 ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9790 unreachable!()
9791 }
9792 }
9793
9794 struct SelectiveHangFactory {
9798 created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
9799 }
9800
9801 impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
9802 fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
9803 self.created.borrow_mut().push(peer_url.to_string());
9804 if peer_url.contains("hang.example.com") {
9805 Box::new(HangingRemote)
9806 } else {
9807 Box::new(MockSyncRemote {
9808 utxos: vec![crate::types::GASPOutput {
9809 txid: "remote_tx1".to_string(),
9810 output_index: 0,
9811 score: 100.0,
9812 }],
9813 })
9814 }
9815 }
9816 }
9817
9818 fn instant_deadline() -> crate::engine::SleepFactory {
9823 std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
9824 }
9825
9826 fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
9827 let mut sync_config: SyncConfiguration = HashMap::new();
9828 sync_config.insert(
9829 "tm_test".to_string(),
9830 SyncTarget::Peers(vec![
9831 "https://hang.example.com".to_string(),
9832 "https://good.example.com".to_string(),
9833 ]),
9834 );
9835 Engine::new(
9836 HashMap::new(),
9837 HashMap::new(),
9838 Box::new(store),
9839 None,
9840 EngineConfig {
9841 sync_configuration: sync_config,
9842 ..Default::default()
9843 },
9844 )
9845 }
9846
9847 #[tokio::test]
9848 async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
9849 let store = std::rc::Rc::new(MemoryStorage::new());
9850 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
9851 let mut engine = two_peer_engine(store.clone());
9852 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
9853 created: created.clone(),
9854 }));
9855 engine.set_peer_sync_budget(instant_deadline(), 1);
9856
9857 let result = engine.start_gasp_sync().await.unwrap();
9858 let topic_result = &result.topics_synced["tm_test"];
9859
9860 assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
9863 assert!(
9864 topic_result.errors[0].contains("budget"),
9865 "{:?}",
9866 topic_result.errors
9867 );
9868 assert_eq!(
9869 created.borrow().as_slice(),
9870 ["https://hang.example.com", "https://good.example.com"],
9871 "both peers attempted this tick"
9872 );
9873
9874 let hang_cursor = store
9877 .get_last_interaction("https://hang.example.com", "tm_test")
9878 .await
9879 .unwrap();
9880 assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
9881 let good_cursor = store
9882 .get_last_interaction("https://good.example.com", "tm_test")
9883 .await
9884 .unwrap();
9885 assert_eq!(good_cursor, 0, "no synthetic graph completed");
9886
9887 let hang_health = store
9889 .get_peer_sync_health("hang.example.com", "tm_test")
9890 .await
9891 .unwrap();
9892 assert_eq!(hang_health.consecutive_failures, 1);
9893 let good_health = store
9894 .get_peer_sync_health("good.example.com", "tm_test")
9895 .await
9896 .unwrap();
9897 assert_eq!(good_health.consecutive_failures, 0);
9898 }
9899
9900 #[tokio::test]
9901 async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
9902 let store = std::rc::Rc::new(MemoryStorage::new());
9903 let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
9904
9905 let mut sync_config: SyncConfiguration = HashMap::new();
9906 sync_config.insert(
9907 "tm_test".to_string(),
9908 SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
9909 );
9910 let mut engine = Engine::new(
9911 HashMap::new(),
9912 HashMap::new(),
9913 Box::new(store.clone()),
9914 None,
9915 EngineConfig {
9916 sync_configuration: sync_config,
9917 ..Default::default()
9918 },
9919 );
9920 engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
9921 created: created.clone(),
9922 }));
9923 engine.set_peer_sync_budget(instant_deadline(), 1);
9924
9925 for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
9929 engine.start_gasp_sync().await.unwrap();
9930 store.advance_clock(900);
9931 assert_eq!(
9932 created.borrow().len() as u64,
9933 i + 1,
9934 "peer attempted while below the threshold"
9935 );
9936 }
9937 let health = store
9938 .get_peer_sync_health("hang.example.com", "tm_test")
9939 .await
9940 .unwrap();
9941 assert_eq!(
9942 health.consecutive_failures,
9943 crate::gasp::PEER_QUARANTINE_THRESHOLD
9944 );
9945
9946 let result = engine.start_gasp_sync().await.unwrap();
9950 assert!(result.topics_synced["tm_test"].errors.is_empty());
9951 assert_eq!(
9952 created.borrow().len() as u64,
9953 crate::gasp::PEER_QUARANTINE_THRESHOLD,
9954 "quarantined peer must be skipped"
9955 );
9956
9957 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
9959 engine.start_gasp_sync().await.unwrap();
9960 assert_eq!(
9961 created.borrow().len() as u64,
9962 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
9963 "re-probe attempted after the window"
9964 );
9965 let health = store
9967 .get_peer_sync_health("hang.example.com", "tm_test")
9968 .await
9969 .unwrap();
9970 assert_eq!(
9971 health.consecutive_failures,
9972 crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
9973 );
9974 let result = engine.start_gasp_sync().await.unwrap();
9975 assert!(
9976 result.topics_synced["tm_test"].errors.is_empty(),
9977 "re-quarantined"
9978 );
9979
9980 store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
9984 engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
9986 let result = engine.start_gasp_sync().await.unwrap();
9987 assert!(result.topics_synced["tm_test"].errors.is_empty());
9988 let health = store
9989 .get_peer_sync_health("hang.example.com", "tm_test")
9990 .await
9991 .unwrap();
9992 assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
9993 }
9994
9995 use crate::broadcaster::ArcBroadcaster;
9998
9999 struct MockArcBroadcaster {
10000 calls: Arc<Mutex<Vec<String>>>,
10001 should_fail: bool,
10002 }
10003
10004 impl MockArcBroadcaster {
10005 fn new() -> Self {
10006 Self {
10007 calls: Arc::new(Mutex::new(Vec::new())),
10008 should_fail: false,
10009 }
10010 }
10011
10012 fn failing() -> Self {
10013 Self {
10014 calls: Arc::new(Mutex::new(Vec::new())),
10015 should_fail: true,
10016 }
10017 }
10018 }
10019
10020 #[async_trait(?Send)]
10021 impl ArcBroadcaster for MockArcBroadcaster {
10022 async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
10023 self.calls.lock().unwrap().push(raw_tx_hex.to_string());
10024 if self.should_fail {
10025 Err("mock ARC failure".to_string())
10026 } else {
10027 Ok("mock_txid_from_arc".to_string())
10028 }
10029 }
10030 }
10031
10032 fn make_engine_with_arc(
10034 admit_indices: Vec<u32>,
10035 arc: MockArcBroadcaster,
10036 ) -> (Engine, Arc<Mutex<Vec<String>>>) {
10037 let arc_calls = arc.calls.clone();
10038
10039 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
10040 managers.insert(
10041 "tm_test".to_string(),
10042 Box::new(MockTopicManager::admitting(admit_indices)),
10043 );
10044
10045 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
10046 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
10047
10048 let storage = Box::new(MemoryStorage::new());
10049
10050 let engine = Engine::with_all(
10051 managers,
10052 lookup_services,
10053 storage,
10054 None,
10055 None,
10056 Some(Box::new(arc)),
10057 None,
10058 EngineConfig::default(),
10059 );
10060
10061 (engine, arc_calls)
10062 }
10063
10064 #[tokio::test]
10067 async fn test_arc_broadcaster_called_on_current_tx() {
10068 let arc = MockArcBroadcaster::new();
10069 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
10070
10071 let beef = test_tagged_beef(vec!["tm_test"]);
10072 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
10073
10074 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
10075
10076 let calls = arc_calls.lock().unwrap();
10077 assert_eq!(
10078 calls.len(),
10079 1,
10080 "ARC broadcaster should be called once for CurrentTx"
10081 );
10082 assert!(
10083 !calls[0].is_empty(),
10084 "ARC should receive non-empty raw tx hex"
10085 );
10086 }
10087
10088 #[tokio::test]
10089 async fn test_arc_broadcaster_not_called_on_historical_tx() {
10090 let arc = MockArcBroadcaster::new();
10091 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
10092
10093 let beef = test_tagged_beef(vec!["tm_test"]);
10094 engine
10095 .submit(&beef, SubmitMode::HistoricalTx)
10096 .await
10097 .unwrap();
10098
10099 assert_eq!(
10100 arc_calls.lock().unwrap().len(),
10101 0,
10102 "ARC broadcaster should NOT be called for HistoricalTx"
10103 );
10104 }
10105
10106 #[tokio::test]
10107 async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
10108 let arc = MockArcBroadcaster::new();
10109 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
10110
10111 let beef = test_tagged_beef(vec!["tm_test"]);
10112 engine
10113 .submit(&beef, SubmitMode::HistoricalTxNoSpv)
10114 .await
10115 .unwrap();
10116
10117 assert_eq!(
10118 arc_calls.lock().unwrap().len(),
10119 0,
10120 "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
10121 );
10122 }
10123
10124 #[tokio::test]
10125 async fn test_arc_broadcast_failure_does_not_fail_submit() {
10126 let arc = MockArcBroadcaster::failing();
10127 let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
10128
10129 let beef = test_tagged_beef(vec!["tm_test"]);
10130 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
10132
10133 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
10134
10135 assert_eq!(
10137 arc_calls.lock().unwrap().len(),
10138 1,
10139 "ARC broadcaster should be attempted even if it will fail"
10140 );
10141 }
10142
10143 #[tokio::test]
10144 async fn test_arc_broadcaster_not_present_still_works() {
10145 let engine = make_engine(vec![0]);
10147
10148 let beef = test_tagged_beef(vec!["tm_test"]);
10149 let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
10150
10151 assert!(!steak["tm_test"].outputs_to_admit.is_empty());
10152 }
10153
10154 struct TrackingAdvertiser {
10158 created: Arc<Mutex<Vec<AdvertisementData>>>,
10159 existing_ship: Vec<Advertisement>,
10160 existing_slap: Vec<Advertisement>,
10161 tag_topics: Vec<String>,
10165 create_fails: bool,
10167 find_fails: bool,
10169 revoke_empty: bool,
10171 }
10172
10173 impl TrackingAdvertiser {
10174 fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
10175 let created = Arc::new(Mutex::new(Vec::new()));
10176 (
10177 Self {
10178 created: created.clone(),
10179 existing_ship: vec![],
10180 existing_slap: vec![],
10181 tag_topics: vec!["tm_test".to_string()],
10182 create_fails: false,
10183 find_fails: false,
10184 revoke_empty: false,
10185 },
10186 created,
10187 )
10188 }
10189 }
10190
10191 use crate::advertiser::{Advertiser, AdvertiserError};
10192
10193 #[async_trait(?Send)]
10194 impl Advertiser for TrackingAdvertiser {
10195 async fn create_advertisements(
10196 &self,
10197 ads: &[AdvertisementData],
10198 ) -> Result<TaggedBEEF, AdvertiserError> {
10199 if self.create_fails {
10200 return Err(AdvertiserError::CreationFailed(
10201 "mock create refused".into(),
10202 ));
10203 }
10204 self.created.lock().unwrap().extend(ads.iter().cloned());
10205 Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
10206 }
10207
10208 async fn find_all_advertisements(
10209 &self,
10210 protocol: Protocol,
10211 ) -> Result<Vec<Advertisement>, AdvertiserError> {
10212 if self.find_fails {
10213 return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
10214 }
10215 match protocol {
10216 Protocol::Ship => Ok(self.existing_ship.clone()),
10217 Protocol::Slap => Ok(self.existing_slap.clone()),
10218 }
10219 }
10220
10221 async fn revoke_advertisements(
10222 &self,
10223 _ads: &[Advertisement],
10224 ) -> Result<TaggedBEEF, AdvertiserError> {
10225 if self.revoke_empty {
10226 return Ok(TaggedBEEF::new(vec![], vec![]));
10227 }
10228 Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
10229 }
10230
10231 fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
10232 None
10233 }
10234 }
10235
10236 #[tokio::test]
10240 async fn test_sync_advertisements_url_validation() {
10241 let (adv, created) = TrackingAdvertiser::new();
10243
10244 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
10245 managers.insert(
10246 "tm_test".to_string(),
10247 Box::new(MockTopicManager::admitting(vec![0])),
10248 );
10249
10250 let engine = Engine::new(
10251 managers,
10252 HashMap::new(),
10253 Box::new(MemoryStorage::new()),
10254 Some(Box::new(adv)),
10255 EngineConfig {
10256 hosting_url: Some(String::new()), ..Default::default()
10258 },
10259 );
10260
10261 engine.sync_advertisements().await.unwrap();
10262 assert!(
10263 created.lock().unwrap().is_empty(),
10264 "Empty hosting URL should skip advertisement creation"
10265 );
10266
10267 let (adv2, created2) = TrackingAdvertiser::new();
10269
10270 let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
10271 managers2.insert(
10272 "tm_test".to_string(),
10273 Box::new(MockTopicManager::admitting(vec![0])),
10274 );
10275 managers2.insert(
10277 "tm_ship".to_string(),
10278 Box::new(MockTopicManager::admitting(vec![])),
10279 );
10280
10281 let engine2 = Engine::new(
10282 managers2,
10283 HashMap::new(),
10284 Box::new(MemoryStorage::new()),
10285 Some(Box::new(adv2)),
10286 EngineConfig {
10287 hosting_url: Some("https://valid.example.com".to_string()),
10288 suppress_default_sync_advertisements: true,
10289 ..Default::default()
10290 },
10291 );
10292
10293 engine2.sync_advertisements().await.unwrap();
10294 let ads = created2.lock().unwrap();
10295 assert!(
10297 ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
10298 "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
10299 );
10300 assert!(
10301 ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
10302 "tm_test should get a SHIP advertisement"
10303 );
10304 }
10305
10306 fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
10315 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
10316 managers.insert(
10317 "tm_test".to_string(),
10318 Box::new(MockTopicManager::admitting(vec![0])),
10319 );
10320 Engine::new(
10321 managers,
10322 HashMap::new(),
10323 Box::new(MemoryStorage::new()),
10324 Some(Box::new(adv)),
10325 EngineConfig {
10326 hosting_url: Some("https://valid.example.com".to_string()),
10327 suppress_default_sync_advertisements: true,
10328 ..Default::default()
10329 },
10330 )
10331 }
10332
10333 #[tokio::test]
10334 async fn sync_report_counts_admitted_outputs_on_success() {
10335 let (adv, _) = TrackingAdvertiser::new();
10336 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
10337 assert!(report.ok(), "clean run must report ok: {report:?}");
10338 assert_eq!(report.to_create, 1, "tm_test was missing");
10339 assert_eq!(
10340 report.admitted.get("tm_test"),
10341 Some(&1),
10342 "the STEAK's admitted count must be surfaced: {report:?}"
10343 );
10344 }
10345
10346 #[tokio::test]
10347 async fn sync_report_surfaces_local_submit_failure() {
10348 let (mut adv, _) = TrackingAdvertiser::new();
10352 adv.tag_topics = vec!["tm_unregistered".to_string()];
10353 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
10354 assert!(!report.ok(), "a failed local submit must not report ok");
10355 assert_eq!(report.to_create, 1);
10356 let submit_error = report.submit_error.as_deref().unwrap_or_default();
10357 assert!(
10358 submit_error.contains("tm_unregistered"),
10359 "verbatim engine error expected, got: {submit_error}"
10360 );
10361 assert!(report.admitted.is_empty());
10362 }
10363
10364 #[tokio::test]
10365 async fn sync_report_surfaces_create_failure() {
10366 let (mut adv, _) = TrackingAdvertiser::new();
10367 adv.create_fails = true;
10368 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
10369 assert!(!report.ok());
10370 assert!(
10371 report
10372 .create_error
10373 .as_deref()
10374 .unwrap_or_default()
10375 .contains("mock create refused"),
10376 "{report:?}"
10377 );
10378 assert!(report.submit_error.is_none(), "create failed before submit");
10379 }
10380
10381 #[tokio::test]
10382 async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
10383 let (mut adv, created) = TrackingAdvertiser::new();
10387 adv.existing_ship = vec![Advertisement {
10388 protocol: Protocol::Ship,
10389 identity_key: "02aa".to_string(),
10390 domain: "https://valid.example.com".to_string(),
10391 topic_or_service: "tm_test".to_string(),
10392 beef: None,
10393 output_index: None,
10394 }];
10395 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
10396 assert!(report.ok(), "{report:?}");
10397 assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
10398 assert!(created.lock().unwrap().is_empty());
10399 assert!(
10400 report.effective(),
10401 "converged-noop is the one zero-admit shape that IS success"
10402 );
10403 }
10404
10405 #[tokio::test]
10406 async fn sync_report_zero_admit_is_ok_but_not_effective() {
10407 let (adv, _) = TrackingAdvertiser::new();
10413 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
10414 managers.insert(
10415 "tm_test".to_string(),
10416 Box::new(MockTopicManager::admitting(vec![])), );
10418 let engine = Engine::new(
10419 managers,
10420 HashMap::new(),
10421 Box::new(MemoryStorage::new()),
10422 Some(Box::new(adv)),
10423 EngineConfig {
10424 hosting_url: Some("https://valid.example.com".to_string()),
10425 suppress_default_sync_advertisements: true,
10426 ..Default::default()
10427 },
10428 );
10429 let report = engine.sync_advertisements().await.unwrap();
10430 assert!(report.ok(), "no ERROR occurred: {report:?}");
10431 assert_eq!(report.to_create, 1);
10432 assert_eq!(report.admitted_total(), 0, "{report:?}");
10433 assert!(
10434 !report.effective(),
10435 "zero admitted on a non-empty create must not read as success: {report:?}"
10436 );
10437 }
10438
10439 #[tokio::test]
10440 async fn sync_report_partial_admit_is_not_effective() {
10441 let (mut adv, _) = TrackingAdvertiser::new();
10447 adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
10448 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
10449 managers.insert(
10450 "tm_test".to_string(),
10451 Box::new(MockTopicManager::admitting(vec![0])),
10452 );
10453 managers.insert(
10454 "tm_refuse".to_string(),
10455 Box::new(MockTopicManager::admitting(vec![])), );
10457 let engine = Engine::new(
10458 managers,
10459 HashMap::new(),
10460 Box::new(MemoryStorage::new()),
10461 Some(Box::new(adv)),
10462 EngineConfig {
10463 hosting_url: Some("https://valid.example.com".to_string()),
10464 suppress_default_sync_advertisements: true,
10465 ..Default::default()
10466 },
10467 );
10468 let report = engine.sync_advertisements().await.unwrap();
10469 assert!(report.ok(), "no ERROR occurred: {report:?}");
10470 assert_eq!(report.to_create, 2, "{report:?}");
10471 assert_eq!(report.admitted_total(), 1, "{report:?}");
10472 assert!(
10473 !report.effective(),
10474 "a partial admit must not read as success: {report:?}"
10475 );
10476 }
10477
10478 #[tokio::test]
10479 async fn sync_report_lookup_failure_refuses_creation() {
10480 let (mut adv, created) = TrackingAdvertiser::new();
10484 adv.find_fails = true;
10485 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
10486 assert!(!report.ok(), "{report:?}");
10487 assert!(!report.effective());
10488 assert!(
10489 report
10490 .lookup_error
10491 .as_deref()
10492 .unwrap_or_default()
10493 .contains("mock lookup refused"),
10494 "{report:?}"
10495 );
10496 assert_eq!(report.to_create, 0, "creation refused, not attempted");
10497 assert!(
10498 created.lock().unwrap().is_empty(),
10499 "no create call on a blind read"
10500 );
10501 assert!(report.submit_error.is_none());
10502 }
10503
10504 #[tokio::test]
10505 async fn sync_report_counts_skipped_revocations() {
10506 let (mut adv, _) = TrackingAdvertiser::new();
10509 adv.revoke_empty = true;
10510 adv.existing_ship = vec![
10511 Advertisement {
10512 protocol: Protocol::Ship,
10513 identity_key: "02aa".to_string(),
10514 domain: "https://valid.example.com".to_string(),
10515 topic_or_service: "tm_test".to_string(),
10516 beef: None,
10517 output_index: None,
10518 },
10519 Advertisement {
10520 protocol: Protocol::Ship,
10521 identity_key: "02aa".to_string(),
10522 domain: "https://valid.example.com".to_string(),
10523 topic_or_service: "tm_stale".to_string(), beef: None,
10525 output_index: None,
10526 },
10527 ];
10528 let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
10529 assert!(report.ok(), "{report:?}");
10530 assert_eq!(report.to_revoke, 1, "tm_stale is stale");
10531 assert_eq!(
10532 report.revoke_skipped, 1,
10533 "the v1 no-op must be visible, not silent: {report:?}"
10534 );
10535 assert!(report.revoke_error.is_none());
10536 assert!(report.revoke_submit_error.is_none());
10537 }
10538
10539 use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
10542 use std::cell::Cell;
10543
10544 const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
10546
10547 fn proofless_beef() -> (Vec<u8>, String) {
10549 use bsv_rs::transaction::{Beef, Transaction};
10550 let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
10551 let txid = tx.id();
10552 let mut beef = Beef::new();
10553 beef.merge_transaction(tx);
10554 (beef.to_binary(), txid)
10555 }
10556
10557 fn subject_not_last_beef() -> (Vec<u8>, String, String) {
10567 use bsv_rs::transaction::Beef;
10568 let parsed = Beef::from_binary(&test_beef()).unwrap();
10569 let parent = parsed.txs[0].tx().unwrap().clone();
10570 let child = parsed.txs[1].tx().unwrap().clone();
10571 let (parent_id, child_id) = (parent.id(), child.id());
10572 assert_ne!(parent_id, child_id);
10573
10574 let mut beef = Beef::new();
10580 beef.merge_transaction(child); beef.merge_transaction(parent);
10582 let mut w = bsv_rs::primitives::encoding::Writer::new();
10583 beef.to_writer(&mut w);
10584 let bytes = w.into_bytes();
10585
10586 let reread = Beef::from_binary(&bytes).unwrap();
10589 assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
10590 (bytes, child_id, parent_id)
10591 }
10592
10593 #[test]
10594 fn stitch_preserves_the_subject_when_it_is_not_last() {
10595 use bsv_rs::transaction::{Beef, MerklePath};
10596 let (stored, child_id, parent_id) = subject_not_last_beef();
10597 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
10598
10599 let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
10600 .expect("stitch must succeed on a beef that contains the txid");
10601
10602 let after = Beef::from_binary(&out).unwrap();
10603 assert!(
10604 after.find_txid(&child_id).is_some(),
10605 "the SUBJECT must survive its own proof-completion"
10606 );
10607 assert!(
10608 after.find_txid(&parent_id).is_some(),
10609 "the ancestry must survive too"
10610 );
10611 assert!(
10612 after.find_bump(&child_id).is_some(),
10613 "the stitched bump must prove the subject"
10614 );
10615 assert!(
10616 after.find_bump(&parent_id).is_none(),
10617 "the parent must NOT be claimed by the child's bump"
10618 );
10619 }
10620
10621 #[test]
10626 fn the_old_round_trip_demonstrably_dropped_the_subject() {
10627 use bsv_rs::transaction::{Beef, Transaction};
10628 let (stored, child_id, _parent_id) = subject_not_last_beef();
10629 let tx = Transaction::from_beef(&stored, None).unwrap();
10630 assert_ne!(
10631 tx.id(),
10632 child_id,
10633 "from_beef(None) picks txs.last(), not the subject — the trigger"
10634 );
10635 let rebuilt = tx.to_beef(true).unwrap();
10636 let reparsed = Beef::from_binary(&rebuilt).unwrap();
10637 assert!(
10638 reparsed.find_txid(&child_id).is_none(),
10639 "the rebuild loses the subject — exactly the #284 artifact"
10640 );
10641 }
10642
10643 #[test]
10649 fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
10650 use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
10651 let parent = Transaction::from_hex(RAW_PARENT).unwrap();
10653 let parent_id = parent.id();
10654 let subject = child_of(&parent_id, 0);
10655 let subject_id = subject.id();
10656 let orphan = MerklePath::new_unchecked(
10659 965_771,
10660 vec![vec![
10661 MerklePathLeaf::new_txid(0, subject_id.clone()),
10662 MerklePathLeaf::new(1, "aa".repeat(32)),
10663 ]],
10664 )
10665 .unwrap();
10666 let mut beef = Beef::new();
10667 let bi = beef.merge_bump(orphan.clone());
10668 beef.merge_raw_tx(parent.to_binary(), None);
10669 beef.merge_raw_tx(subject.to_binary(), Some(bi));
10670 let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
10671 let stored = beef.to_binary();
10672 assert_eq!(
10673 Beef::from_binary(&stored)
10674 .unwrap()
10675 .find_bump(&subject_id)
10676 .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
10677 Some(orphan_root.clone())
10678 );
10679 let canonical = MerklePath::new_unchecked(
10681 965_771,
10682 vec![vec![
10683 MerklePathLeaf::new_txid(0, subject_id.clone()),
10684 MerklePathLeaf::new(1, "bb".repeat(32)),
10685 ]],
10686 )
10687 .unwrap();
10688 let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
10689 assert_ne!(
10690 canonical_root, orphan_root,
10691 "the reorg shape: same height, new root"
10692 );
10693 let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
10694 let after = Beef::from_binary(&out).unwrap();
10695 let own = after
10697 .find_txid(&subject_id)
10698 .and_then(BeefTx::bump_index)
10699 .and_then(|bi| after.bumps.get(bi))
10700 .unwrap();
10701 assert_eq!(
10702 own.compute_root(Some(&subject_id)).unwrap(),
10703 canonical_root,
10704 "the subject re-anchored to the new bump"
10705 );
10706 assert_eq!(
10708 after.bumps.len(),
10709 1,
10710 "the unreferenced orphan bump was dropped"
10711 );
10712 assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
10714 }
10715
10716 #[test]
10717 fn stitch_refuses_a_beef_that_lacks_the_txid() {
10718 use bsv_rs::transaction::MerklePath;
10719 let (stored, _c, _p) = subject_not_last_beef();
10720 let foreign = "aa".repeat(32);
10721 let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
10722 assert!(
10723 Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
10724 "no txid in the beef -> no write, never garbage"
10725 );
10726 }
10727
10728 const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
10730
10731 fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
10733 let mut sx = String::from("0100000001");
10734 let mut prev = hex::decode(source_txid).unwrap();
10735 prev.reverse();
10736 sx.push_str(&hex::encode(prev));
10737 sx.push_str(&hex::encode(vout.to_le_bytes()));
10738 sx.push_str("00ffffffff01");
10739 sx.push_str(&hex::encode(1000u64.to_le_bytes()));
10740 sx.push_str("015100000000");
10741 bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
10742 }
10743
10744 fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
10746 use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
10747 let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
10748 MerklePath::new_unchecked(height, vec![vec![leaf]])
10749 .unwrap()
10750 .to_hex()
10751 }
10752
10753 struct StubFetcher {
10756 proof: Option<String>,
10757 calls: Cell<u32>,
10758 }
10759
10760 #[async_trait(?Send)]
10761 impl AncestorFetcher for StubFetcher {
10762 async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
10763 self.calls.set(self.calls.get() + 1);
10764 Ok(FetchedAncestor {
10765 raw_tx: PROOFLESS_RAW_TX.to_string(),
10766 proof: self.proof.clone(),
10767 })
10768 }
10769
10770 async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
10776 true
10777 }
10778 }
10779
10780 async fn engine_with_proofless_output(
10783 fetcher: Option<std::rc::Rc<StubFetcher>>,
10784 ) -> (Engine, String) {
10785 let (beef, txid) = proofless_beef();
10786 let storage = MemoryStorage::new();
10787 storage
10788 .insert_output(&Output {
10789 txid: txid.clone(),
10790 output_index: 0,
10791 output_script: vec![0x76],
10792 satoshis: 1_000_000_000,
10793 topic: "Hello".to_string(),
10794 spent: false,
10795 outputs_consumed: vec![],
10796 consumed_by: vec![],
10797 beef: Some(beef),
10798 block_height: None,
10799 score: Some(1.0),
10800 })
10801 .await
10802 .unwrap();
10803
10804 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10805 managers.insert(
10806 "Hello".into(),
10807 Box::new(MockTopicManager::admitting(vec![0])),
10808 );
10809 let mut engine = Engine::new(
10810 managers,
10811 HashMap::new(),
10812 Box::new(storage),
10813 None,
10814 EngineConfig::default(),
10815 );
10816 if let Some(f) = fetcher {
10817 engine.set_ancestor_fetcher(f);
10818 }
10819 (engine, txid)
10820 }
10821
10822 #[tokio::test]
10823 async fn proofs_completion_uses_dedicated_courier() {
10824 let (_, txid) = proofless_beef();
10825 let shared = std::rc::Rc::new(StubFetcher {
10826 proof: None,
10827 calls: Cell::new(0),
10828 });
10829 let dedicated = StubFetcher {
10830 proof: Some(single_leaf_bump_hex(&txid, 850_000)),
10831 calls: Cell::new(0),
10832 };
10833 let (engine, _) = engine_with_proofless_output(Some(shared.clone())).await;
10834 assert_eq!(
10835 engine
10836 .complete_missing_proofs(1, 0)
10837 .await
10838 .unwrap()
10839 .completed,
10840 0
10841 );
10842 assert_eq!(
10843 engine
10844 .complete_missing_proofs_with(&dedicated, 1, 0)
10845 .await
10846 .unwrap()
10847 .completed,
10848 1
10849 );
10850 assert_eq!(shared.calls.get(), 1);
10851 assert_eq!(dedicated.calls.get(), 1);
10852 let output = engine
10853 .storage()
10854 .find_output(&txid, 0, Some("Hello"), None, true)
10855 .await
10856 .unwrap()
10857 .unwrap();
10858 assert!(
10859 crate::beef_limits::own_proof(output.beef.as_ref().unwrap(), &txid)
10860 .unwrap()
10861 .is_some()
10862 );
10863 }
10864
10865 #[tokio::test]
10866 async fn complete_missing_proofs_noop_without_fetcher() {
10867 let (engine, _txid) = engine_with_proofless_output(None).await;
10869 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10870 assert_eq!(summary, ProofCompletionSummary::default());
10871 }
10872
10873 #[tokio::test]
10874 async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
10875 let (_beef, txid) = proofless_beef();
10876 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
10877 let fetcher = std::rc::Rc::new(StubFetcher {
10878 proof: Some(proof_hex),
10879 calls: Cell::new(0),
10880 });
10881 let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
10882
10883 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10884 assert_eq!(summary.scanned, 1);
10885 assert_eq!(summary.proofless, 1);
10886 assert_eq!(
10887 summary.completed, 1,
10888 "the proofless BEEF should be completed"
10889 );
10890 assert_eq!(fetcher.calls.get(), 1);
10891
10892 let out = engine
10895 .storage()
10896 .find_output(&txid, 0, Some("Hello"), None, true)
10897 .await
10898 .unwrap()
10899 .unwrap();
10900 assert_eq!(out.block_height, Some(850_000));
10901 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
10902 assert!(
10903 beef.find_txid(&txid)
10904 .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
10905 "completed BEEF must prove the target tx"
10906 );
10907
10908 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
10911 assert_eq!(summary2.proofless, 0);
10912 assert_eq!(summary2.completed, 0);
10913 assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
10914 }
10915
10916 #[tokio::test]
10917 async fn complete_missing_proofs_skips_unconfirmed() {
10918 let fetcher = std::rc::Rc::new(StubFetcher {
10921 proof: None,
10922 calls: Cell::new(0),
10923 });
10924 let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
10925 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
10926 assert_eq!(summary.proofless, 1);
10927 assert_eq!(summary.completed, 0);
10928 assert_eq!(summary.still_unconfirmed, 1);
10929 assert_eq!(fetcher.calls.get(), 1);
10930 }
10931
10932 async fn engine_with_proofless_output_shared(
10937 fetcher: Option<std::rc::Rc<StubFetcher>>,
10938 ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
10939 let (beef, txid) = proofless_beef();
10940 let storage = std::rc::Rc::new(MemoryStorage::new());
10941 storage
10942 .insert_output(&Output {
10943 txid: txid.clone(),
10944 output_index: 0,
10945 output_script: vec![0x76],
10946 satoshis: 1_000_000_000,
10947 topic: "Hello".to_string(),
10948 spent: false,
10949 outputs_consumed: vec![],
10950 consumed_by: vec![],
10951 beef: Some(beef),
10952 block_height: None,
10953 score: Some(1.0),
10954 })
10955 .await
10956 .unwrap();
10957
10958 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
10959 managers.insert(
10960 "Hello".into(),
10961 Box::new(MockTopicManager::admitting(vec![0])),
10962 );
10963 let mut engine = Engine::new(
10964 managers,
10965 HashMap::new(),
10966 Box::new(storage.clone()),
10967 None,
10968 EngineConfig::default(),
10969 );
10970 if let Some(f) = fetcher {
10971 engine.set_ancestor_fetcher(f);
10972 }
10973 (engine, txid, storage)
10974 }
10975
10976 #[tokio::test]
10977 async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
10978 let proof_hex = {
10982 let (_beef, txid) = proofless_beef();
10983 single_leaf_bump_hex(&txid, 850_000)
10984 };
10985 let fetcher = std::rc::Rc::new(StubFetcher {
10986 proof: Some(proof_hex),
10987 calls: Cell::new(0),
10988 });
10989 let (engine, _txid, storage) =
10990 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
10991
10992 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
10993 assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
10994 assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
10995
10996 storage.advance_clock(1800);
11001 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
11002 assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
11003 assert_eq!(summary.completed, 1, "the backstop completes the proof");
11004 assert_eq!(fetcher.calls.get(), 1);
11005 }
11006
11007 #[tokio::test]
11008 async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
11009 let fetcher = std::rc::Rc::new(StubFetcher {
11015 proof: None,
11016 calls: Cell::new(0),
11017 });
11018 let (engine, txid, storage) =
11019 engine_with_proofless_output_shared(Some(fetcher.clone())).await;
11020
11021 let proof_hex = single_leaf_bump_hex(&txid, 850_000);
11024 engine
11025 .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
11026 .await
11027 .unwrap();
11028
11029 storage.advance_clock(1_000_000);
11032 let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
11033 assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
11034 assert_eq!(summary.completed, 0);
11035 assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
11036 }
11037
11038 #[tokio::test]
11039 async fn complete_missing_proofs_respects_limit() {
11040 let (beef1, txid1) = proofless_beef();
11042 let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
11044 let (beef2, txid2) = {
11045 use bsv_rs::transaction::{Beef, Transaction};
11046 let tx = Transaction::from_hex(raw2).unwrap();
11047 let id = tx.id();
11048 let mut b = Beef::new();
11049 b.merge_transaction(tx);
11050 (b.to_binary(), id)
11051 };
11052
11053 let storage = MemoryStorage::new();
11054 for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
11055 storage
11056 .insert_output(&Output {
11057 txid: txid.clone(),
11058 output_index: 0,
11059 output_script: vec![0x76],
11060 satoshis: 1000,
11061 topic: "Hello".to_string(),
11062 spent: false,
11063 outputs_consumed: vec![],
11064 consumed_by: vec![],
11065 beef: Some(beef),
11066 block_height: None,
11067 score: Some(1.0),
11068 })
11069 .await
11070 .unwrap();
11071 }
11072
11073 let fetcher = std::rc::Rc::new(StubFetcher {
11074 proof: None,
11075 calls: Cell::new(0),
11076 });
11077 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
11078 managers.insert(
11079 "Hello".into(),
11080 Box::new(MockTopicManager::admitting(vec![0])),
11081 );
11082 let mut engine = Engine::new(
11083 managers,
11084 HashMap::new(),
11085 Box::new(storage),
11086 None,
11087 EngineConfig::default(),
11088 );
11089 engine.set_ancestor_fetcher(fetcher.clone());
11090
11091 let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
11092 assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
11093 }
11094
11095 #[tokio::test]
11096 async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
11097 use bsv_rs::transaction::{Beef, MerklePath, Transaction};
11106
11107 let storage = MemoryStorage::new();
11108
11109 let proven_raws = [
11114 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
11115 "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
11116 ];
11117 let mut proven_ids = Vec::new();
11118 for raw in proven_raws {
11119 let mut tx = Transaction::from_hex(raw).unwrap();
11120 let id = tx.id();
11121 tx.merkle_path =
11122 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
11123 let proven_beef = tx.to_beef(true).unwrap();
11126 assert_eq!(
11127 Beef::from_binary(&proven_beef)
11128 .unwrap()
11129 .find_txid(&id)
11130 .map(bsv_rs::transaction::BeefTx::has_proof),
11131 Some(true),
11132 "proven fixture row must carry its proof"
11133 );
11134 proven_ids.push(id.clone());
11135 storage
11136 .insert_output(&Output {
11137 txid: id,
11138 output_index: 0,
11139 output_script: vec![0x76],
11140 satoshis: 1000,
11141 topic: "Hello".to_string(),
11142 spent: false,
11143 outputs_consumed: vec![],
11144 consumed_by: vec![],
11145 beef: Some(proven_beef),
11146 block_height: Some(800_000),
11147 score: Some(1.0),
11148 })
11149 .await
11150 .unwrap();
11151 }
11152
11153 let fetcher = std::rc::Rc::new(StubFetcher {
11154 proof: None,
11155 calls: Cell::new(0),
11156 });
11157 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
11158 managers.insert(
11159 "Hello".into(),
11160 Box::new(MockTopicManager::admitting(vec![0])),
11161 );
11162 let mut engine = Engine::new(
11163 managers,
11164 HashMap::new(),
11165 Box::new(storage),
11166 None,
11167 EngineConfig::default(),
11168 );
11169 engine.set_ancestor_fetcher(fetcher.clone());
11170
11171 let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
11174 assert_eq!(summary.scanned, proven_ids.len());
11175 assert_eq!(summary.proofless, 0);
11176 assert_eq!(summary.completed, 0);
11177 assert_eq!(
11178 summary.already_proven,
11179 proven_ids.len(),
11180 "every already-proven row is marked this pass"
11181 );
11182 assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
11183
11184 let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
11187 assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
11188 assert_eq!(summary2.already_proven, 0);
11189 }
11190
11191 #[tokio::test]
11192 async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
11193 use bsv_rs::transaction::{MerklePath, Transaction};
11199
11200 let storage = MemoryStorage::new();
11201
11202 let proven_raws = [
11203 "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
11204 ];
11205 for raw in proven_raws {
11206 let mut tx = Transaction::from_hex(raw).unwrap();
11207 let id = tx.id();
11208 tx.merkle_path =
11209 Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
11210 let proven_beef = tx.to_beef(true).unwrap();
11211 storage
11212 .insert_output(&Output {
11213 txid: id,
11214 output_index: 0,
11215 output_script: vec![0x76],
11216 satoshis: 1000,
11217 topic: "Hello".to_string(),
11218 spent: false,
11219 outputs_consumed: vec![],
11220 consumed_by: vec![],
11221 beef: Some(proven_beef),
11222 block_height: Some(800_000),
11223 score: Some(1.0),
11224 })
11225 .await
11226 .unwrap();
11227 }
11228
11229 let (proofless, proofless_txid) = proofless_beef();
11231 storage
11232 .insert_output(&Output {
11233 txid: proofless_txid.clone(),
11234 output_index: 0,
11235 output_script: vec![0x76],
11236 satoshis: 1000,
11237 topic: "Hello".to_string(),
11238 spent: false,
11239 outputs_consumed: vec![],
11240 consumed_by: vec![],
11241 beef: Some(proofless),
11242 block_height: None,
11243 score: Some(1.0),
11244 })
11245 .await
11246 .unwrap();
11247
11248 let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
11249 let fetcher = std::rc::Rc::new(StubFetcher {
11250 proof: Some(proof_hex),
11251 calls: Cell::new(0),
11252 });
11253 let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
11254 managers.insert(
11255 "Hello".into(),
11256 Box::new(MockTopicManager::admitting(vec![0])),
11257 );
11258 let mut engine = Engine::new(
11259 managers,
11260 HashMap::new(),
11261 Box::new(storage),
11262 None,
11263 EngineConfig::default(),
11264 );
11265 engine.set_ancestor_fetcher(fetcher.clone());
11266
11267 let mut completed_total = 0;
11270 for _ in 0..5 {
11271 let s = engine.complete_missing_proofs(1, 0).await.unwrap();
11272 completed_total += s.completed;
11273 if completed_total > 0 {
11274 break;
11275 }
11276 }
11277 assert_eq!(
11278 completed_total, 1,
11279 "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
11280 );
11281
11282 let out = engine
11283 .storage()
11284 .find_output(&proofless_txid, 0, Some("Hello"), None, true)
11285 .await
11286 .unwrap()
11287 .unwrap();
11288 let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
11289 assert_eq!(
11290 beef.find_txid(&proofless_txid)
11291 .map(bsv_rs::transaction::BeefTx::has_proof),
11292 Some(true),
11293 "the old proofless tx now carries its proof"
11294 );
11295 }
11296
11297 use crate::storage::StorageError;
11305 use std::rc::Rc;
11306
11307 struct FaultingStorage {
11312 inner: MemoryStorage,
11313 fail_insert_output: Cell<bool>,
11314 fail_find_output: Cell<bool>,
11315 }
11316
11317 impl FaultingStorage {
11318 fn new() -> Rc<Self> {
11319 Rc::new(Self {
11320 inner: MemoryStorage::new(),
11321 fail_insert_output: Cell::new(false),
11322 fail_find_output: Cell::new(false),
11323 })
11324 }
11325 }
11326
11327 #[async_trait(?Send)]
11328 impl Storage for FaultingStorage {
11329 async fn put_deferred_graph(
11330 &self,
11331 record: &crate::gasp::DeferredGraph,
11332 ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
11333 self.inner.put_deferred_graph(record).await
11334 }
11335 async fn find_deferred_graphs(
11336 &self,
11337 host: &str,
11338 topic: &str,
11339 ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
11340 self.inner.find_deferred_graphs(host, topic).await
11341 }
11342 async fn get_deferred_graph(
11343 &self,
11344 host: &str,
11345 topic: &str,
11346 outpoint: &str,
11347 ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
11348 self.inner.get_deferred_graph(host, topic, outpoint).await
11349 }
11350 async fn delete_deferred_graph(
11351 &self,
11352 host: &str,
11353 topic: &str,
11354 outpoint: &str,
11355 ) -> Result<(), StorageError> {
11356 self.inner
11357 .delete_deferred_graph(host, topic, outpoint)
11358 .await
11359 }
11360 async fn record_peer_sync_outcome(
11361 &self,
11362 host: &str,
11363 topic: &str,
11364 success: bool,
11365 ) -> Result<(), StorageError> {
11366 self.inner
11367 .record_peer_sync_outcome(host, topic, success)
11368 .await
11369 }
11370 async fn get_peer_sync_health(
11371 &self,
11372 host: &str,
11373 topic: &str,
11374 ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
11375 self.inner.get_peer_sync_health(host, topic).await
11376 }
11377 async fn record_peer_sync_yield(
11378 &self,
11379 host: &str,
11380 topic: &str,
11381 yielded: bool,
11382 ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
11383 self.inner
11384 .record_peer_sync_yield(host, topic, yielded)
11385 .await
11386 }
11387 async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
11388 if self.fail_insert_output.get() {
11389 return Err(StorageError::Database(
11390 "D1_ERROR: storage overloaded".into(),
11391 ));
11392 }
11393 self.inner.insert_output(output).await
11394 }
11395 async fn delete_output(
11396 &self,
11397 txid: &str,
11398 output_index: u32,
11399 topic: &str,
11400 ) -> Result<(), StorageError> {
11401 self.inner.delete_output(txid, output_index, topic).await
11402 }
11403 async fn mark_utxo_as_spent(
11404 &self,
11405 txid: &str,
11406 output_index: u32,
11407 topic: &str,
11408 ) -> Result<(), StorageError> {
11409 self.inner
11410 .mark_utxo_as_spent(txid, output_index, topic)
11411 .await
11412 }
11413 async fn update_consumed_by(
11414 &self,
11415 txid: &str,
11416 output_index: u32,
11417 topic: &str,
11418 consumed_by: &[Outpoint],
11419 ) -> Result<(), StorageError> {
11420 self.inner
11421 .update_consumed_by(txid, output_index, topic, consumed_by)
11422 .await
11423 }
11424 async fn update_transaction_beef(
11425 &self,
11426 txid: &str,
11427 beef: &[u8],
11428 ) -> Result<(), StorageError> {
11429 self.inner.update_transaction_beef(txid, beef).await
11430 }
11431 async fn insert_applied_transaction(
11432 &self,
11433 tx: &AppliedTransaction,
11434 ) -> Result<(), StorageError> {
11435 self.inner.insert_applied_transaction(tx).await
11436 }
11437 async fn does_applied_transaction_exist(
11438 &self,
11439 tx: &AppliedTransaction,
11440 ) -> Result<bool, StorageError> {
11441 self.inner.does_applied_transaction_exist(tx).await
11442 }
11443 async fn delete_applied_transaction(
11444 &self,
11445 tx: &AppliedTransaction,
11446 ) -> Result<(), StorageError> {
11447 self.inner.delete_applied_transaction(tx).await
11448 }
11449 async fn find_output(
11450 &self,
11451 txid: &str,
11452 output_index: u32,
11453 topic: Option<&str>,
11454 spent: Option<bool>,
11455 include_beef: bool,
11456 ) -> Result<Option<Output>, StorageError> {
11457 if self.fail_find_output.get() {
11458 return Err(StorageError::Database("D1_ERROR: read timed out".into()));
11459 }
11460 self.inner
11461 .find_output(txid, output_index, topic, spent, include_beef)
11462 .await
11463 }
11464 async fn find_outputs_for_transaction(
11465 &self,
11466 txid: &str,
11467 include_beef: bool,
11468 ) -> Result<Vec<Output>, StorageError> {
11469 self.inner
11470 .find_outputs_for_transaction(txid, include_beef)
11471 .await
11472 }
11473 async fn find_utxos_for_topic(
11474 &self,
11475 topic: &str,
11476 since: Option<f64>,
11477 limit: Option<u64>,
11478 include_beef: bool,
11479 ) -> Result<Vec<Output>, StorageError> {
11480 self.inner
11481 .find_utxos_for_topic(topic, since, limit, include_beef)
11482 .await
11483 }
11484 async fn update_last_interaction(
11485 &self,
11486 host: &str,
11487 topic: &str,
11488 since: u64,
11489 ) -> Result<(), StorageError> {
11490 self.inner.update_last_interaction(host, topic, since).await
11491 }
11492 async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
11493 self.inner.get_last_interaction(host, topic).await
11494 }
11495 }
11496
11497 fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
11498 let storage = FaultingStorage::new();
11499 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11500 managers.insert(
11501 "tm_test".to_string(),
11502 Box::new(MockTopicManager::admitting(admit_indices)),
11503 );
11504 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
11505 lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
11506 let engine = Engine::new(
11507 managers,
11508 lookup_services,
11509 Box::new(Rc::clone(&storage)),
11510 None,
11511 EngineConfig::default(),
11512 );
11513 (engine, storage)
11514 }
11515
11516 async fn applied(storage: &FaultingStorage) -> bool {
11517 storage
11518 .does_applied_transaction_exist(&AppliedTransaction {
11519 txid: TEST_TXID.to_string(),
11520 topic: "tm_test".to_string(),
11521 })
11522 .await
11523 .unwrap()
11524 }
11525
11526 #[tokio::test]
11529 async fn clean_submit_is_durable_and_records_applied() {
11530 let (engine, storage) = make_faulting_engine(vec![0]);
11531 let (steak, report) = engine
11532 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11533 .await
11534 .unwrap();
11535 assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
11536 assert!(report.is_durable(), "{}", report.summary());
11537 assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
11538 assert!(applied(&storage).await);
11539 assert!(storage
11540 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
11541 .await
11542 .unwrap()
11543 .is_some());
11544 }
11545
11546 #[tokio::test]
11551 async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
11552 let (engine, storage) = make_faulting_engine(vec![0]);
11553 storage.fail_insert_output.set(true);
11554 let (steak, report) = engine
11555 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11556 .await
11557 .unwrap();
11558 assert_eq!(
11559 steak["tm_test"].outputs_to_admit,
11560 vec![0],
11561 "the admission DECISION is unchanged by a write fault"
11562 );
11563 assert!(!report.is_durable());
11564 assert_eq!(report.faults.len(), 1, "{}", report.summary());
11565 assert_eq!(report.faults[0].topic, "tm_test");
11566 assert_eq!(report.faults[0].site, "insert_output");
11567 assert!(report.faults[0].error.contains("D1_ERROR"));
11568 assert!(report.applied_topics.is_empty());
11569 assert!(
11570 !applied(&storage).await,
11571 "a faulted topic must not be recorded as applied — the replay would be a dupe"
11572 );
11573 assert!(storage
11574 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
11575 .await
11576 .unwrap()
11577 .is_none());
11578 }
11579
11580 #[tokio::test]
11584 async fn replay_after_a_fault_reapplies_and_records_applied() {
11585 let (engine, storage) = make_faulting_engine(vec![0]);
11586 storage.fail_insert_output.set(true);
11587 let (_, first) = engine
11588 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11589 .await
11590 .unwrap();
11591 assert!(!first.is_durable());
11592
11593 storage.fail_insert_output.set(false);
11594 let (steak, replay) = engine
11595 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11596 .await
11597 .unwrap();
11598 assert_eq!(
11599 steak["tm_test"].outputs_to_admit,
11600 vec![0],
11601 "re-validated, not deduped"
11602 );
11603 assert!(replay.is_durable(), "{}", replay.summary());
11604 assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
11605 assert!(applied(&storage).await);
11606 assert!(storage
11607 .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
11608 .await
11609 .unwrap()
11610 .is_some());
11611
11612 let (steak, third) = engine
11613 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11614 .await
11615 .unwrap();
11616 assert!(
11617 steak["tm_test"].outputs_to_admit.is_empty(),
11618 "now a genuine dupe: nothing admitted"
11619 );
11620 assert!(third.is_durable() && third.applied_topics.is_empty());
11621 }
11622
11623 #[tokio::test]
11629 async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
11630 let (engine, storage) = make_faulting_engine(vec![0]);
11631 let tx = Transaction::from_beef(&test_beef(), None).unwrap();
11634 let source_txid = tx.inputs[0].get_source_txid().unwrap();
11635 let source_vout = tx.inputs[0].source_output_index;
11636 storage
11637 .insert_output(&Output {
11638 txid: source_txid.clone(),
11639 output_index: source_vout,
11640 output_script: vec![0x51],
11641 satoshis: 1,
11642 topic: "tm_test".to_string(),
11643 spent: false,
11644 outputs_consumed: vec![],
11645 consumed_by: vec![],
11646 beef: None,
11647 block_height: None,
11648 score: Some(1.0),
11649 })
11650 .await
11651 .unwrap();
11652
11653 storage.fail_find_output.set(true);
11654 let (_, faulted) = engine
11655 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11656 .await
11657 .unwrap();
11658 assert!(!faulted.is_durable());
11659 assert!(
11660 faulted.faults.iter().any(|f| f.site == "find_output"),
11661 "{}",
11662 faulted.summary()
11663 );
11664 assert!(
11665 !applied(&storage).await,
11666 "unrecorded: the replay must re-read"
11667 );
11668 storage.fail_find_output.set(false);
11669 assert!(
11670 storage
11671 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
11672 .await
11673 .unwrap()
11674 .is_some(),
11675 "the coin the faulted pass could not see is still there, untouched"
11676 );
11677
11678 let (_, replay) = engine
11679 .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
11680 .await
11681 .unwrap();
11682 assert!(replay.is_durable(), "{}", replay.summary());
11683 assert!(applied(&storage).await);
11684 assert!(
11687 storage
11688 .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
11689 .await
11690 .unwrap()
11691 .is_none(),
11692 "the healthy replay consumed the coin the faulted pass missed"
11693 );
11694 }
11695
11696 #[tokio::test]
11703 async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
11704 use std::cell::RefCell;
11705 use std::rc::Rc;
11706
11707 struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
11708 #[async_trait(?Send)]
11709 impl LookupServiceTrait for CountingLs {
11710 fn admission_mode(&self) -> AdmissionMode {
11711 self.1
11712 }
11713 fn spend_notification_mode(&self) -> SpendNotificationMode {
11714 SpendNotificationMode::None
11715 }
11716 async fn output_admitted_by_topic(
11717 &self,
11718 payload: &OutputAdmittedByTopic,
11719 ) -> Result<(), LookupServiceError> {
11720 let rec = match payload {
11721 OutputAdmittedByTopic::LockingScript {
11722 txid,
11723 output_index,
11724 topic,
11725 ..
11726 } => (txid.clone(), *output_index, topic.clone()),
11727 OutputAdmittedByTopic::WholeTx {
11728 atomic_beef,
11729 output_index,
11730 topic,
11731 ..
11732 } => {
11733 let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
11734 (
11735 beef.atomic_txid
11736 .clone()
11737 .expect("the WholeTx body NAMES its subject"),
11738 *output_index,
11739 topic.clone(),
11740 )
11741 }
11742 };
11743 self.0.borrow_mut().push(rec);
11744 Ok(())
11745 }
11746 async fn output_evicted(
11747 &self,
11748 _txid: &str,
11749 _output_index: u32,
11750 ) -> Result<(), LookupServiceError> {
11751 Ok(())
11752 }
11753 async fn lookup(
11754 &self,
11755 _question: &LookupQuestion,
11756 ) -> Result<LookupResult, LookupServiceError> {
11757 Ok(LookupResult::OutputList(Vec::new()))
11758 }
11759 async fn get_documentation(&self) -> String {
11760 "counting".to_string()
11761 }
11762 async fn get_metadata(&self) -> ServiceMetadata {
11763 ServiceMetadata {
11764 name: "counting-ls".to_string(),
11765 ..Default::default()
11766 }
11767 }
11768 }
11769
11770 let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11771 managers.insert(
11772 "tm_test".to_string(),
11773 Box::new(MockTopicManager::admitting(vec![0])),
11774 );
11775 let seen = Rc::new(RefCell::new(Vec::new()));
11776 let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
11777 lookup_services.insert(
11778 "ls_whole".to_string(),
11779 Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
11780 );
11781 lookup_services.insert(
11782 "ls_script".to_string(),
11783 Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
11784 );
11785 let engine = Engine::new(
11786 managers,
11787 lookup_services,
11788 Box::new(MemoryStorage::new()),
11789 None,
11790 EngineConfig::default(),
11791 );
11792
11793 let tagged = test_tagged_beef(vec!["tm_test"]);
11794 engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
11795 assert_eq!(seen.borrow().len(), 2, "one admit per service");
11796 let (txid, vout, _) = seen.borrow()[0].clone();
11797 assert_eq!(txid, TEST_TXID);
11798
11799 let (_, report) = engine
11801 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11802 .await
11803 .unwrap();
11804 assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
11805 assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
11806
11807 let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
11809 assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
11810 assert_eq!(r.vouts, vec![vout]);
11811 assert_eq!(seen.borrow().len(), 4);
11812 assert!(seen.borrow()[2..]
11813 .iter()
11814 .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
11815
11816 let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
11818 assert_eq!((r0.outputs, r0.notified), (0, 0));
11819 assert_eq!(seen.borrow().len(), 4);
11820
11821 let r2 = engine
11823 .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
11824 .await
11825 .unwrap();
11826 assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
11827 assert_eq!(seen.borrow().len(), 6);
11828 }
11829
11830 #[tokio::test]
11835 async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
11836 let engine = make_engine(vec![]);
11838 let tagged = test_tagged_beef(vec!["tm_test"]);
11839 let (steak, r1) = engine
11840 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11841 .await
11842 .unwrap();
11843 assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
11844 assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
11845 let (_, r2) = engine
11846 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11847 .await
11848 .unwrap();
11849 assert_eq!(
11850 r2.deduped_topics,
11851 vec!["tm_test".to_string()],
11852 "the phantom row dedups the re-submit"
11853 );
11854 assert!(engine
11855 .forget_phantom_applied(TEST_TXID, "tm_test")
11856 .await
11857 .unwrap());
11858 let (_, r3) = engine
11859 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11860 .await
11861 .unwrap();
11862 assert!(
11863 r3.deduped_topics.is_empty(),
11864 "judged again after the forget"
11865 );
11866 assert!(
11867 !engine
11868 .forget_phantom_applied(TEST_TXID, "tm_other")
11869 .await
11870 .unwrap(),
11871 "no row: nothing to forget"
11872 );
11873
11874 let real = make_engine(vec![0]);
11876 real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
11877 assert!(!real
11878 .forget_phantom_applied(TEST_TXID, "tm_test")
11879 .await
11880 .unwrap());
11881 let (_, r4) = real
11882 .submit_with_report(&tagged, SubmitMode::CurrentTx)
11883 .await
11884 .unwrap();
11885 assert_eq!(
11886 r4.deduped_topics,
11887 vec!["tm_test".to_string()],
11888 "a real admission still dedups"
11889 );
11890 }
11891
11892 const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
11898
11899 const WIDE_HEIGHT: u32 = 800_001;
11901
11902 fn wide_leaves(k: u32) -> Vec<String> {
11904 (0..1u32 << k)
11905 .map(|i| {
11906 let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
11907 id.reverse();
11908 hex::encode(id)
11909 })
11910 .collect()
11911 }
11912
11913 fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
11917 let k = txids.len().trailing_zeros() as usize;
11918 let mut levels = vec![Vec::new(); k];
11919 levels[0] = txids
11920 .iter()
11921 .enumerate()
11922 .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
11923 .collect();
11924 let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
11925 let root = bump.compute_root(None).unwrap();
11926 (bump, root)
11927 }
11928
11929 fn w1_bytes(k: u32) -> (Vec<u8>, String) {
11932 let (bump, root) = wide_bump(&wide_leaves(k));
11933 let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
11934 beef.extend_from_slice(&bump.to_binary());
11935 beef.push(0x00);
11936 (beef, root)
11937 }
11938
11939 fn walk_within_bound(
11943 bytes: Vec<u8>,
11944 root: String,
11945 verify_scripts: bool,
11946 subject: String,
11947 what: &str,
11948 ) -> (Result<(), String>, std::time::Duration) {
11949 let (tx, rx) = std::sync::mpsc::channel();
11950 std::thread::spawn(move || {
11951 let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
11952 tracker.add_root(WIDE_HEIGHT, root);
11953 let runtime = tokio::runtime::Builder::new_current_thread()
11954 .build()
11955 .unwrap();
11956 let started = std::time::Instant::now();
11957 let verdict = runtime.block_on(verify_spv_like_the_reference(
11958 Some(&tracker),
11959 verify_scripts,
11960 &bytes,
11961 &subject,
11962 DoorBudget::DEFAULT,
11963 ));
11964 let verdict = match verdict {
11965 Ok(None) => Ok(()),
11966 Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
11967 Err(e) => Err(e.to_string()),
11968 };
11969 let _ = tx.send((verdict, started.elapsed()));
11970 });
11971 rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
11972 panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
11973 })
11974 }
11975
11976 #[test]
11980 fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
11981 for k in [12u32, 13] {
11982 let (bytes, root) = w1_bytes(k);
11983 let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
11984 let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
11985 println!(
11986 "NL-6e W1 {what}: {verdict:?} in {:.3} s",
11987 took.as_secs_f64()
11988 );
11989 assert_eq!(verdict, Ok(()), "{what}");
11990 }
11991 }
11992
11993 fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
11998 use bsv_rs::script::{LockingScript, UnlockingScript};
11999 use bsv_rs::transaction::{TransactionInput, TransactionOutput};
12000 let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
12001 let spending = |txid: &str| {
12002 let mut input = TransactionInput::new(txid.to_string(), 0);
12003 input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
12004 input
12005 };
12006 let parents: Vec<Transaction> = (0..1u32 << k)
12007 .map(|i| {
12008 let mut tx = Transaction::new();
12009 let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
12011 input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
12012 tx.inputs.push(input);
12013 tx.outputs.push(TransactionOutput::new(1, op_true()));
12014 tx
12015 })
12016 .collect();
12017 let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
12018 let (bump, root) = wide_bump(&ids);
12019 let mut beef = Beef::new();
12020 let at = beef.merge_bump(bump);
12021 for parent in &parents {
12022 beef.merge_raw_tx(parent.to_binary(), Some(at));
12023 }
12024 let mut layer = ids;
12025 while layer.len() > 1 {
12026 layer = layer
12027 .chunks(2)
12028 .map(|pair| {
12029 let mut tx = Transaction::new();
12030 tx.inputs.push(spending(&pair[0]));
12031 tx.inputs.push(spending(&pair[1]));
12032 tx.outputs.push(TransactionOutput::new(0, op_true()));
12033 beef.merge_raw_tx(tx.to_binary(), None);
12034 tx.id()
12035 })
12036 .collect();
12037 }
12038 (beef.to_binary(), root, layer.remove(0))
12039 }
12040
12041 #[test]
12042 fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
12043 for k in [12u32, 13] {
12044 let (bytes, root, subject) = proven_fan_in(k);
12045 let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
12046 let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
12047 println!(
12048 "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
12049 took.as_secs_f64()
12050 );
12051 assert_eq!(verdict, Ok(()), "{what}");
12052 }
12053 }
12054}