1use std::collections::{BTreeMap, HashMap, VecDeque};
62use std::convert::Infallible;
63
64use anyhow::Result;
65use bsv_sdk::transaction::MerklePath;
66use bsv_sdk::wallet::{InternalizeActionArgs, WalletInterface};
67use bsv_tracker::{
68 decode_envelope, ChainEnvelope, ChainEvent, CheckError, Clock, Evidence, Header, HeaderError,
69 Headers, Height, Hint, HintSource, HintStatus, HostAction, Input, Params, Proof, ProofFetcher,
70 Reask, State, Timestamp, Tracker, TxId, Word,
71};
72use bsv_wallet_toolbox::services::PostBeefResult;
73use bsv_wallet_toolbox::{BroadcastStatus, StorageSqlx, WalletServices};
74use serde::{Deserialize, Serialize};
75use sqlx::Row as _;
76
77pub const DEFAULT_AGE_SECS: u64 = 600;
81pub const DEFAULT_MAX_ASKS: usize = 20;
83const MAX_BACKOFF_DOUBLINGS: u32 = 6;
86const MAX_ASKS_PER_TX: u32 = 16;
89const TIP_RING: usize = 12;
91pub const DEFAULT_SPEND_DEPTH: Height = 1;
94
95#[derive(Debug, Clone)]
104pub struct HeaderSnapshot {
105 tip: std::result::Result<(Height, String), String>,
106 headers: BTreeMap<Height, std::result::Result<Header, String>>,
107}
108
109impl HeaderSnapshot {
110 pub async fn take<V: WalletServices + ?Sized>(
112 services: &V,
113 heights: impl IntoIterator<Item = Height>,
114 ) -> Self {
115 let tip = services
116 .get_chain_tip_header()
117 .await
118 .map(|h| (h.height, h.hash.to_ascii_lowercase()))
119 .map_err(|e| e.to_string());
120 let mut snapshot = Self {
121 tip,
122 headers: BTreeMap::new(),
123 };
124 snapshot.extend(services, heights).await;
125 snapshot
126 }
127
128 pub fn unread() -> Self {
130 Self {
131 tip: Err("the header service was not read".to_string()),
132 headers: BTreeMap::new(),
133 }
134 }
135
136 pub async fn extend<V: WalletServices + ?Sized>(
138 &mut self,
139 services: &V,
140 heights: impl IntoIterator<Item = Height>,
141 ) {
142 for height in heights {
143 if self.headers.contains_key(&height) {
144 continue;
145 }
146 let answer = match services.get_header_for_height(height).await {
147 Ok(bytes) => header_of(&bytes),
148 Err(e) => Err(e.to_string()),
149 };
150 self.headers.insert(height, answer);
151 }
152 }
153
154 pub fn tip(&self) -> Option<(Height, &str)> {
156 self.tip.as_ref().ok().map(|(h, hash)| (*h, hash.as_str()))
157 }
158}
159
160impl Headers for HeaderSnapshot {
161 fn header_at(&self, height: Height) -> std::result::Result<Option<Header>, HeaderError> {
162 match self.headers.get(&height) {
163 Some(Ok(header)) => Ok(Some(header.clone())),
164 Some(Err(fault)) => Err(HeaderError(fault.clone())),
165 None => Ok(None),
166 }
167 }
168
169 fn tip_height(&self) -> std::result::Result<Height, HeaderError> {
170 self.tip
171 .as_ref()
172 .map(|(height, _)| *height)
173 .map_err(|fault| HeaderError(fault.clone()))
174 }
175}
176
177fn header_of(bytes: &[u8]) -> std::result::Result<Header, String> {
180 if bytes.len() != 80 {
181 return Err(format!("a header of {} bytes", bytes.len()));
182 }
183 let display = |raw: &[u8]| {
184 let mut reversed = raw.to_vec();
185 reversed.reverse();
186 hex::encode(reversed)
187 };
188 Ok(Header {
189 hash: display(&bsv_sdk::primitives::hash::sha256d(bytes)),
190 merkle_root: display(&bytes[36..68]),
191 })
192}
193
194#[derive(Debug, Clone, Copy, Default)]
200pub struct SystemClock;
201
202impl Clock for SystemClock {
203 fn now(&self) -> Timestamp {
204 std::time::SystemTime::now()
205 .duration_since(std::time::UNIX_EPOCH)
206 .map(|d| d.as_secs())
207 .unwrap_or(0)
208 }
209}
210
211#[derive(Debug, Default)]
220pub struct MemoryHints {
221 queue: VecDeque<(TxId, Hint)>,
222}
223
224impl MemoryHints {
225 async fn read(storage: &StorageSqlx, rows: &[TrackedRow]) -> Result<Self> {
226 let txids: Vec<String> = rows.iter().map(|r| r.txid.clone()).collect();
227 if txids.is_empty() {
228 return Ok(Self::default());
229 }
230 let mut records = storage.broadcast_records(None, &txids).await?;
231 records.sort_by_key(|r| r.seen_at);
232 let mut queue = VecDeque::new();
233 for record in records {
234 let Some(status) = record.ladder_status() else {
235 continue;
236 };
237 let txid = record.txid.to_ascii_lowercase();
238 let Some(row) = rows.iter().find(|r| r.txid == txid) else {
239 continue;
240 };
241 let (label, status) = match status {
244 BroadcastStatus::Accepted => ("accepted", HintStatus::Accepted),
245 BroadcastStatus::Seen => ("seen", HintStatus::Seen),
246 BroadcastStatus::Mined => ("mined", HintStatus::Unknown),
247 BroadcastStatus::Rejected => (
248 "rejected",
249 HintStatus::Rejected {
250 reason: record.status.clone(),
251 },
252 ),
253 BroadcastStatus::Unknown => ("unknown", HintStatus::Unknown),
254 };
255 let source = format!("{}|{}", record.provider, label);
256 if row.hints.iter().any(|h| h.source == source) {
257 continue;
258 }
259 let observed = record.seen_at.timestamp().max(0) as Timestamp;
260 queue.push_back((txid, Hint::new(source, status, observed)));
261 }
262 Ok(Self { queue })
263 }
264}
265
266impl HintSource for MemoryHints {
267 type Error = Infallible;
268
269 fn next_hint(&mut self) -> std::result::Result<Option<(TxId, Hint)>, Self::Error> {
270 Ok(self.queue.pop_front())
271 }
272}
273
274#[derive(Debug, Default)]
282pub struct Courier {
283 carried: HashMap<TxId, std::result::Result<Option<Proof>, String>>,
284}
285
286impl Courier {
287 pub async fn carry<V: WalletServices + ?Sized>(services: &V, txids: &[TxId]) -> Self {
289 let mut carried = HashMap::new();
290 for txid in txids {
291 let answer = match services.get_merkle_path(txid, false).await {
292 Ok(found) => match found.merkle_path {
293 Some(hex) => MerklePath::from_hex(&hex)
294 .map_err(|e| format!("not a merkle path: {e}"))
295 .and_then(|path| Proof::new(txid.clone(), path).map_err(|e| e.to_string()))
296 .map(Some),
297 None => Ok(None),
298 },
299 Err(e) => Err(e.to_string()),
300 };
301 carried.insert(txid.clone(), answer);
302 }
303 Self { carried }
304 }
305
306 fn heights(&self) -> Vec<Height> {
307 self.carried
308 .values()
309 .filter_map(|answer| answer.as_ref().ok()?.as_ref().map(Proof::height))
310 .collect()
311 }
312}
313
314impl ProofFetcher for Courier {
315 type Error = String;
316
317 fn fetch(
318 &mut self,
319 txid: &str,
320 _reask: &Reask,
321 ) -> std::result::Result<Option<Proof>, Self::Error> {
322 self.carried.remove(txid).unwrap_or(Ok(None))
323 }
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331struct StoredHint {
332 source: String,
333 status: String,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 height: Option<Height>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 reason: Option<String>,
338 #[serde(default, skip_serializing_if = "Vec::is_empty")]
339 competitors: Vec<TxId>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
342 courier: Option<String>,
343 observed: Timestamp,
344}
345
346impl StoredHint {
347 fn of(hint: &Hint) -> Self {
348 let mut courier = None;
349 let (status, height, reason, competitors) = match &hint.status {
350 HintStatus::Accepted => ("accepted", None, None, vec![]),
351 HintStatus::Seen => ("seen", None, None, vec![]),
352 HintStatus::Mined { height } => ("mined", Some(*height), None, vec![]),
353 HintStatus::StaleBlock => ("stale_block", None, None, vec![]),
354 HintStatus::Rejected { reason } => ("rejected", None, Some(reason.clone()), vec![]),
355 HintStatus::DoubleSpend { competitors } => {
356 ("double_spend", None, None, competitors.clone())
357 }
358 HintStatus::OrphanMempool => ("orphan_mempool", None, None, vec![]),
359 HintStatus::Unknown => ("unknown", None, None, vec![]),
360 HintStatus::MempoolPresent { source } => {
361 courier = Some(source.clone());
362 ("mempool_present", None, None, vec![])
363 }
364 };
365 Self {
366 source: hint.source.clone(),
367 status: status.to_string(),
368 height,
369 reason,
370 competitors,
371 courier,
372 observed: hint.observed,
373 }
374 }
375
376 fn hint(&self) -> Hint {
377 let status = match self.status.as_str() {
378 "accepted" => HintStatus::Accepted,
379 "seen" => HintStatus::Seen,
380 "mined" => self
381 .height
382 .map_or(HintStatus::Unknown, |height| HintStatus::Mined { height }),
383 "stale_block" => HintStatus::StaleBlock,
384 "rejected" => HintStatus::Rejected {
385 reason: self.reason.clone().unwrap_or_default(),
386 },
387 "double_spend" => HintStatus::DoubleSpend {
388 competitors: self.competitors.clone(),
389 },
390 "orphan_mempool" => HintStatus::OrphanMempool,
391 "mempool_present" => self.courier.clone().map_or(HintStatus::Unknown, |source| {
392 HintStatus::MempoolPresent { source }
393 }),
394 _ => HintStatus::Unknown,
395 };
396 Hint::new(self.source.clone(), status, self.observed)
397 }
398}
399
400#[derive(Debug, Clone, PartialEq, Eq)]
402struct TrackedRow {
403 txid: TxId,
404 built: bool,
405 hints: Vec<StoredHint>,
407 bump: Option<String>,
409 asks: u32,
410 next_ask_at: Option<Timestamp>,
411 reannounce_beef: Option<Vec<u8>>,
413 reannounce_at: Option<Timestamp>,
414 on_accept: Option<String>,
417 chain: Vec<String>,
421}
422
423impl TrackedRow {
424 fn new(txid: &str) -> Self {
425 Self {
426 txid: txid.to_ascii_lowercase(),
427 built: true,
428 hints: vec![],
429 bump: None,
430 asks: 0,
431 next_ask_at: None,
432 reannounce_beef: None,
433 reannounce_at: None,
434 on_accept: None,
435 chain: vec![],
436 }
437 }
438
439 fn chain_events(&self) -> Vec<ChainEvent> {
443 self.chain
444 .iter()
445 .filter_map(|json| match decode_envelope(json.as_bytes()) {
446 Ok(event) => Some(event),
447 Err(fault) => {
448 tracing::warn!(txid = %self.txid, %fault, "a stored chain event does not decode");
449 None
450 }
451 })
452 .collect()
453 }
454
455 fn keep_chain(&mut self, state: &State, event: &ChainEvent) -> Result<()> {
459 let plain = matches!(state.word(), Word::Mined(_)) && !state.suspect();
460 if plain && state.reask().is_none() {
461 self.chain.clear();
462 return Ok(());
463 }
464 if matches!(event, ChainEvent::Tip { .. } | ChainEvent::TipAge { .. }) {
465 self.chain.retain(|json| {
466 !matches!(
467 decode_envelope(json.as_bytes()),
468 Ok(ChainEvent::Tip { .. } | ChainEvent::TipAge { .. })
469 )
470 });
471 }
472 self.chain
473 .push(serde_json::to_string(&ChainEnvelope::new(event.clone()))?);
474 Ok(())
475 }
476
477 fn replay_into<H: Headers>(&self, tracker: &mut Tracker, headers: &H) {
480 tracker.track(self.txid.clone());
481 if self.built {
482 let _ = tracker.apply(&self.txid, headers, Input::Host(HostAction::Build));
483 }
484 for hint in &self.hints {
485 let _ = tracker.apply(&self.txid, headers, Input::Hint(hint.hint()));
486 }
487 if let Some(Ok(proof)) = self.proof() {
488 let _ = tracker.apply(&self.txid, headers, Input::Evidence(Evidence::Proof(proof)));
489 }
490 for event in self.chain_events() {
491 let _ = tracker.apply(&self.txid, headers, Input::Evidence(Evidence::Chain(event)));
492 }
493 }
494
495 fn proof(&self) -> Option<std::result::Result<Proof, CheckError>> {
496 let hex = self.bump.as_deref()?;
497 Some(
498 MerklePath::from_hex(hex)
499 .map_err(|e| CheckError::InvalidProof(e.to_string()))
500 .and_then(|path| Proof::new(self.txid.clone(), path)),
501 )
502 }
503
504 fn proof_height(&self) -> Option<Height> {
505 self.proof()?.ok().map(|p| p.height())
506 }
507
508 fn replay<H: Headers>(&self, params: &Params, headers: &H) -> (State, Option<CheckError>) {
512 let mut state = State::new(self.txid.clone());
513 if self.built {
514 let _ = state.step(params, headers, Input::Host(HostAction::Build));
515 }
516 for hint in &self.hints {
517 let _ = state.step(params, headers, Input::Hint(hint.hint()));
518 }
519 let fault = match self.proof() {
520 Some(Ok(proof)) => state
521 .step(params, headers, Input::Evidence(Evidence::Proof(proof)))
522 .err(),
523 Some(Err(fault)) => Some(fault),
524 None => None,
525 };
526 for event in self.chain_events() {
527 let _ = state.step(params, headers, Input::Evidence(Evidence::Chain(event)));
528 }
529 (state, fault)
530 }
531}
532
533pub fn word_label(word: &Word) -> &'static str {
535 match word {
536 Word::Unknown => "unknown",
537 Word::Built => "built",
538 Word::Announced => "announced",
539 Word::Seen => "seen",
540 Word::Mined(_) => "mined",
541 Word::Stale => "stale",
542 Word::Rejected { .. } => "rejected",
543 Word::Conflicted { .. } => "conflicted",
544 Word::Abandoned => "abandoned",
545 }
546}
547
548fn reask_label(reask: &Reask) -> String {
549 match reask {
550 Reask::Reorg { fork_height } => format!("reorg@{fork_height}"),
551 Reask::Fork { height } => format!("fork@{height}"),
552 Reask::Hint(hint) => format!("hint:{}", hint.source),
553 Reask::ProofFailed { height } => format!("proof_failed@{height}"),
554 Reask::Recheck => "recheck".to_string(),
555 Reask::Age => "age".to_string(),
556 Reask::Spend => "spend".to_string(),
557 }
558}
559
560async fn ensure_tables(storage: &StorageSqlx) -> Result<()> {
561 for sql in [
562 "CREATE TABLE IF NOT EXISTS tracker_states (\
563 txid TEXT PRIMARY KEY, \
564 built INTEGER NOT NULL DEFAULT 1, \
565 hints TEXT NOT NULL DEFAULT '[]', \
566 bump TEXT, \
567 word TEXT NOT NULL DEFAULT 'unknown', \
568 height INTEGER, \
569 header_hash TEXT, \
570 reask TEXT, \
571 asks INTEGER NOT NULL DEFAULT 0, \
572 next_ask_at INTEGER, \
573 reannounce_beef BLOB, \
574 reannounce_at INTEGER, \
575 on_accept TEXT, \
576 updated_at INTEGER NOT NULL DEFAULT 0)",
577 "CREATE INDEX IF NOT EXISTS tracker_states_word ON tracker_states (word)",
578 "CREATE INDEX IF NOT EXISTS tracker_states_height ON tracker_states (height)",
579 "CREATE TABLE IF NOT EXISTS tracker_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
580 ] {
581 sqlx::query(sql).execute(storage.pool()).await?;
582 }
583 let has_chain: i64 = sqlx::query_scalar(
586 "SELECT COUNT(*) FROM pragma_table_info('tracker_states') WHERE name = 'chain'",
587 )
588 .fetch_one(storage.pool())
589 .await?;
590 if has_chain == 0 {
591 sqlx::query("ALTER TABLE tracker_states ADD COLUMN chain TEXT NOT NULL DEFAULT '[]'")
592 .execute(storage.pool())
593 .await?;
594 }
595 Ok(())
596}
597
598const ROW_COLUMNS: &str =
599 "txid, built, hints, bump, asks, next_ask_at, reannounce_beef, reannounce_at, on_accept, chain";
600
601fn row_of(row: &sqlx::sqlite::SqliteRow) -> TrackedRow {
602 let hints: String = row.get("hints");
603 let chain: String = row.get("chain");
604 TrackedRow {
605 txid: row.get("txid"),
606 built: row.get::<i64, _>("built") != 0,
607 hints: serde_json::from_str(&hints).unwrap_or_default(),
608 bump: row.get("bump"),
609 asks: row.get::<i64, _>("asks").max(0) as u32,
610 next_ask_at: row
611 .get::<Option<i64>, _>("next_ask_at")
612 .map(|t| t.max(0) as Timestamp),
613 reannounce_beef: row.get("reannounce_beef"),
614 reannounce_at: row
615 .get::<Option<i64>, _>("reannounce_at")
616 .map(|t| t.max(0) as Timestamp),
617 on_accept: row.get("on_accept"),
618 chain: serde_json::from_str(&chain).unwrap_or_default(),
619 }
620}
621
622async fn load_row(storage: &StorageSqlx, txid: &str) -> Result<Option<TrackedRow>> {
623 let row = sqlx::query(&format!(
624 "SELECT {ROW_COLUMNS} FROM tracker_states WHERE txid = ?"
625 ))
626 .bind(txid.to_ascii_lowercase())
627 .fetch_optional(storage.pool())
628 .await?;
629 Ok(row.as_ref().map(row_of))
630}
631
632async fn store_row(
633 storage: &StorageSqlx,
634 row: &TrackedRow,
635 state: &State,
636 now: Timestamp,
637) -> Result<()> {
638 store_row_in(storage.pool(), row, state, now).await
639}
640
641async fn store_row_in<'e, E: sqlx::Executor<'e, Database = sqlx::Sqlite>>(
642 executor: E,
643 row: &TrackedRow,
644 state: &State,
645 now: Timestamp,
646) -> Result<()> {
647 let (height, header_hash) = match state.word() {
648 Word::Mined(mined) => (
649 Some(mined.height() as i64),
650 Some(mined.checked().header().hash.clone()),
651 ),
652 _ => (None, None),
653 };
654 sqlx::query(
655 "INSERT INTO tracker_states \
656 (txid, built, hints, bump, word, height, header_hash, reask, asks, next_ask_at, \
657 reannounce_beef, reannounce_at, on_accept, chain, updated_at) \
658 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
659 ON CONFLICT(txid) DO UPDATE SET built = excluded.built, hints = excluded.hints, \
660 bump = excluded.bump, word = excluded.word, height = excluded.height, \
661 header_hash = excluded.header_hash, reask = excluded.reask, asks = excluded.asks, \
662 next_ask_at = excluded.next_ask_at, reannounce_beef = excluded.reannounce_beef, \
663 reannounce_at = excluded.reannounce_at, on_accept = excluded.on_accept, \
664 chain = excluded.chain, updated_at = excluded.updated_at",
665 )
666 .bind(&row.txid)
667 .bind(row.built as i64)
668 .bind(serde_json::to_string(&row.hints)?)
669 .bind(&row.bump)
670 .bind(word_label(state.word()))
671 .bind(height)
672 .bind(header_hash)
673 .bind(state.reask().map(reask_label))
674 .bind(row.asks as i64)
675 .bind(row.next_ask_at.map(|t| t as i64))
676 .bind(&row.reannounce_beef)
677 .bind(row.reannounce_at.map(|t| t as i64))
678 .bind(&row.on_accept)
679 .bind(serde_json::to_string(&row.chain)?)
680 .bind(now as i64)
681 .execute(executor)
682 .await?;
683 Ok(())
684}
685
686pub async fn stored_word(
690 storage: &StorageSqlx,
691 txid: &str,
692) -> Result<Option<(String, Option<Height>, Option<String>)>> {
693 ensure_tables(storage).await?;
694 let row = sqlx::query("SELECT word, height, reask FROM tracker_states WHERE txid = ?")
695 .bind(txid.to_ascii_lowercase())
696 .fetch_optional(storage.pool())
697 .await?;
698 Ok(row.map(|r| {
699 (
700 r.get("word"),
701 r.get::<Option<i64>, _>("height").map(|h| h as Height),
702 r.get("reask"),
703 )
704 }))
705}
706
707#[derive(Debug, Clone, PartialEq, Eq)]
713pub enum PostAnswer {
714 Accepted { provider: String },
716 NotFinal { provider: String, said: String },
719 Refused { said: String },
721}
722
723pub fn says_not_final(text: &str) -> bool {
727 let lower = text.to_ascii_lowercase();
728 ["non-final", "nonfinal", "non final", "not final"]
729 .iter()
730 .any(|word| lower.contains(word))
731 || lower
732 .split(|c: char| !c.is_ascii_alphanumeric())
733 .any(|token| token == "476")
734}
735
736pub fn read_post(results: &[PostBeefResult]) -> PostAnswer {
738 if let Some(accepted) = results.iter().find(|r| r.is_success()) {
739 return PostAnswer::Accepted {
740 provider: accepted.name.clone(),
741 };
742 }
743 let said: Vec<(String, String)> = results
744 .iter()
745 .map(|r| {
746 let detail = r
747 .error
748 .clone()
749 .or_else(|| r.txid_results.iter().find_map(|t| t.data.clone()))
750 .unwrap_or_else(|| r.status.clone());
751 (r.name.clone(), detail)
752 })
753 .collect();
754 if let Some((provider, detail)) = said.iter().find(|(_, detail)| says_not_final(detail)) {
755 return PostAnswer::NotFinal {
756 provider: provider.clone(),
757 said: detail.clone(),
758 };
759 }
760 PostAnswer::Refused {
761 said: if said.is_empty() {
762 "none answered".to_string()
763 } else {
764 said.iter()
765 .map(|(name, detail)| format!("{name}: {detail}"))
766 .collect::<Vec<_>>()
767 .join("; ")
768 },
769 }
770}
771
772#[derive(Debug, Clone)]
774pub struct Reannounce {
775 pub beef: Vec<u8>,
777 pub at: Timestamp,
779 pub on_accept: Option<InternalizeActionArgs>,
781}
782
783pub async fn heard<C: Clock>(
790 storage: &StorageSqlx,
791 clock: &C,
792 opts: &TickOptions,
793 txid: &str,
794 hint: Hint,
795 not_before: Option<Timestamp>,
796 reannounce: Option<Reannounce>,
797) -> Result<(String, Option<String>)> {
798 ensure_tables(storage).await?;
799 let mut row = match load_row(storage, txid).await? {
800 Some(row) if row.bump.is_some() => {
803 let stored = stored_word(storage, txid).await?.unwrap_or_default();
804 return Ok((stored.0, stored.2));
805 }
806 Some(row) => row,
807 None => TrackedRow::new(txid),
808 };
809 let params = opts.params();
810 let headers = HeaderSnapshot::unread();
811 let (mut state, _) = row.replay(¶ms, &headers);
812 if !row.hints.iter().any(|h| h.source == hint.source) {
813 row.hints.push(StoredHint::of(&hint));
814 let _ = state.step(¶ms, &headers, Input::Hint(hint));
815 }
816 row.asks = 0;
817 row.next_ask_at = not_before;
818 match reannounce {
819 Some(again) => {
820 row.reannounce_beef = Some(again.beef);
821 row.reannounce_at = Some(again.at);
822 row.on_accept = match again.on_accept {
823 Some(args) => Some(serde_json::to_string(&args)?),
824 None => None,
825 };
826 }
827 None => {
828 row.reannounce_beef = None;
829 row.reannounce_at = None;
830 row.on_accept = None;
831 }
832 }
833 store_row(storage, &row, &state, clock.now()).await?;
834 Ok((
835 word_label(state.word()).to_string(),
836 state.reask().map(reask_label),
837 ))
838}
839
840#[derive(Debug, Clone)]
846pub struct TickOptions {
847 pub age_threshold: Timestamp,
850 pub max_asks: usize,
852 pub spend_depth: Height,
860}
861
862impl TickOptions {
863 pub fn from_env() -> Self {
866 fn env<T: std::str::FromStr>(key: &str, default: T) -> T {
867 std::env::var(key)
868 .ok()
869 .and_then(|v| v.trim().parse().ok())
870 .unwrap_or(default)
871 }
872 Self {
873 age_threshold: env("TRACKER_AGE_SECS", DEFAULT_AGE_SECS).max(1),
874 max_asks: env("TRACKER_MAX_ASKS", DEFAULT_MAX_ASKS),
875 spend_depth: env("SPEND_DEPTH", DEFAULT_SPEND_DEPTH),
876 }
877 .floored()
878 }
879
880 #[must_use]
882 pub fn floored(mut self) -> Self {
883 self.spend_depth = self.spend_depth.max(DEFAULT_SPEND_DEPTH);
884 self
885 }
886
887 fn params(&self) -> Params {
888 Params {
889 age_threshold: self.age_threshold,
890 }
891 }
892}
893
894#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
896pub struct TickReport {
897 pub tracked: usize,
899 pub adopted: usize,
901 pub adopted_mined: Vec<String>,
905 pub adopted_proof_failed: Vec<String>,
908 pub hints: usize,
910 pub asked: usize,
912 pub mined: Vec<String>,
914 pub no_proof: Vec<String>,
916 pub moved: Vec<String>,
918 pub moved_from: Option<Height>,
920 pub reannounced: Vec<String>,
922 pub not_final: Vec<String>,
925 #[serde(skip)]
929 pub on_accept: Vec<(String, String)>,
930 pub faults: Vec<String>,
932 pub stored: Vec<String>,
934}
935
936impl TickReport {
937 pub fn is_quiet(&self) -> bool {
939 self.adopted == 0
940 && self.adopted_mined.is_empty()
941 && self.adopted_proof_failed.is_empty()
942 && self.hints == 0
943 && self.asked == 0
944 && self.reannounced.is_empty()
945 && self.not_final.is_empty()
946 && self.moved.is_empty()
947 && self.faults.is_empty()
948 }
949
950 pub fn summary(&self) -> String {
952 format!(
953 "tracker tick: {} tracked ({} new), {} proven creator(s) adopted ({} mined, {} proof failed), {} hint(s), {} announced again ({} still not final), {} re-ask(s): {} mined, {} without a proof, {} moved, {} fault(s)",
954 self.tracked,
955 self.adopted,
956 self.adopted_mined.len() + self.adopted_proof_failed.len(),
957 self.adopted_mined.len(),
958 self.adopted_proof_failed.len(),
959 self.hints,
960 self.reannounced.len(),
961 self.not_final.len(),
962 self.asked,
963 self.mined.len(),
964 self.no_proof.len(),
965 self.moved.len(),
966 self.faults.len(),
967 )
968 }
969}
970
971async fn read_ring(storage: &StorageSqlx) -> Vec<(Height, String)> {
973 sqlx::query("SELECT value FROM tracker_meta WHERE key = 'tips'")
974 .fetch_optional(storage.pool())
975 .await
976 .ok()
977 .flatten()
978 .and_then(|r| serde_json::from_str(&r.get::<String, _>("value")).ok())
979 .unwrap_or_default()
980}
981
982async fn write_ring(storage: &StorageSqlx, ring: &[(Height, String)]) -> Result<()> {
983 sqlx::query(
984 "INSERT INTO tracker_meta (key, value) VALUES ('tips', ?) \
985 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
986 )
987 .bind(serde_json::to_string(ring)?)
988 .execute(storage.pool())
989 .await?;
990 Ok(())
991}
992
993async fn moved_since<V: WalletServices + ?Sized>(
999 services: &V,
1000 snapshot: &mut HeaderSnapshot,
1001 mut ring: Vec<(Height, String)>,
1002) -> (Option<Height>, Vec<(Height, String)>) {
1003 let Some((tip_height, tip_hash)) = snapshot.tip().map(|(h, hash)| (h, hash.to_string())) else {
1004 return (None, ring);
1005 };
1006 let mut moved = None;
1007 while let Some((height, hash)) = ring.last().cloned() {
1008 let active = if height == tip_height {
1009 Some(tip_hash.clone())
1010 } else if height > tip_height {
1011 None
1012 } else {
1013 snapshot.extend(services, [height]).await;
1014 match snapshot.header_at(height) {
1015 Ok(Some(header)) => Some(header.hash),
1016 _ => return (moved, ring),
1018 }
1019 };
1020 if active.as_deref() == Some(hash.as_str()) {
1021 break;
1022 }
1023 moved = Some(height);
1024 ring.pop();
1025 }
1026 if ring.last().map(|(h, _)| *h) != Some(tip_height) {
1027 ring.push((tip_height, tip_hash));
1028 }
1029 if ring.len() > TIP_RING {
1030 let extra = ring.len() - TIP_RING;
1031 ring.drain(..extra);
1032 }
1033 (moved, ring)
1034}
1035
1036pub async fn tick<V: WalletServices + ?Sized, C: Clock>(
1038 storage: &StorageSqlx,
1039 services: &V,
1040 clock: &C,
1041 opts: &TickOptions,
1042) -> Result<TickReport> {
1043 ensure_tables(storage).await?;
1044 let params = opts.params();
1045 let now = clock.now();
1046 let mut report = TickReport::default();
1047
1048 let adopted = sqlx::query(
1051 "INSERT OR IGNORE INTO tracker_states (txid, word, updated_at) \
1052 SELECT DISTINCT lower(txid), 'built', ? FROM transactions \
1053 WHERE txid IS NOT NULL AND status IN ('unproven', 'sending')",
1054 )
1055 .bind(now as i64)
1056 .execute(storage.pool())
1057 .await?;
1058 report.adopted = adopted.rows_affected() as usize;
1059
1060 let mut snapshot = HeaderSnapshot::take(services, []).await;
1063 let (moved_from, ring) = moved_since(services, &mut snapshot, read_ring(storage).await).await;
1064 report.moved_from = moved_from;
1065
1066 let mut rows: Vec<TrackedRow> = sqlx::query(&format!(
1067 "SELECT {ROW_COLUMNS} FROM tracker_states \
1068 WHERE word IN ('unknown', 'built', 'announced', 'seen', 'stale') \
1069 OR (word = 'mined' AND ? IS NOT NULL AND height >= ?) \
1070 ORDER BY txid"
1071 ))
1072 .bind(moved_from.map(|h| h as i64))
1073 .bind(moved_from.map(|h| h as i64))
1074 .fetch_all(storage.pool())
1075 .await?
1076 .iter()
1077 .map(row_of)
1078 .collect();
1079 report.tracked = rows.len();
1080
1081 snapshot
1083 .extend(services, rows.iter().filter_map(TrackedRow::proof_height))
1084 .await;
1085 let mut hints = MemoryHints::read(storage, &rows).await?;
1086
1087 let mut states: HashMap<TxId, State> = HashMap::new();
1089 for row in rows.iter_mut() {
1090 let (state, fault) = row.replay(¶ms, &snapshot);
1091 if let Some(fault) = fault {
1092 if matches!(fault, CheckError::RootMismatch(_)) {
1095 row.bump = None;
1098 row.chain.clear();
1099 report.moved.push(row.txid.clone());
1100 tracing::warn!(
1101 marker = "tracker_stored_proof_moved",
1102 txid = %row.txid,
1103 "a stored merkle path no longer meets the active header; asking again"
1104 );
1105 } else {
1106 report.faults.push(format!("{}: {fault}", row.txid));
1107 }
1108 row.asks = 0;
1109 row.next_ask_at = None;
1110 }
1111 states.insert(row.txid.clone(), state);
1112 }
1113 while let Ok(Some((txid, hint))) = hints.next_hint() {
1114 let (Some(state), Some(row)) = (
1115 states.get_mut(&txid),
1116 rows.iter_mut().find(|r| r.txid == txid),
1117 ) else {
1118 continue;
1119 };
1120 row.hints.push(StoredHint::of(&hint));
1121 let _ = state.step(¶ms, &snapshot, Input::Hint(hint));
1122 report.hints += 1;
1123 if matches!(state.reask(), Some(Reask::Hint(_))) {
1126 row.asks = 0;
1127 row.next_ask_at = None;
1128 }
1129 }
1130 for row in rows.iter_mut() {
1136 let (Some(beef), Some(at)) = (row.reannounce_beef.clone(), row.reannounce_at) else {
1137 continue;
1138 };
1139 let Some(state) = states.get_mut(&row.txid) else {
1140 continue;
1141 };
1142 if at > now || matches!(state.word(), Word::Mined(_)) {
1143 continue;
1144 }
1145 let pause = Some(now.saturating_add(opts.age_threshold));
1146 let answer = services
1147 .post_beef(&beef, std::slice::from_ref(&row.txid))
1148 .await
1149 .map(|results| read_post(&results));
1150 match answer {
1151 Ok(PostAnswer::Accepted { provider }) => {
1152 let hint = Hint::new(format!("{provider}|accepted"), HintStatus::Accepted, now);
1153 row.hints.push(StoredHint::of(&hint));
1154 let _ = state.step(¶ms, &snapshot, Input::Hint(hint));
1155 row.reannounce_beef = None;
1156 row.reannounce_at = None;
1157 row.asks = 0;
1158 row.next_ask_at = pause;
1159 report.reannounced.push(row.txid.clone());
1160 if let Some(args) = row.on_accept.take() {
1161 report.on_accept.push((row.txid.clone(), args));
1162 }
1163 }
1164 Ok(PostAnswer::NotFinal { .. }) => {
1165 row.asks = row.asks.saturating_add(1);
1166 row.next_ask_at = pause;
1167 if row.asks >= MAX_ASKS_PER_TX {
1168 row.reannounce_at = None;
1169 report.faults.push(format!(
1170 "{}: still not final after {MAX_ASKS_PER_TX} announces; not announced again",
1171 row.txid
1172 ));
1173 } else {
1174 row.reannounce_at = pause;
1175 report.not_final.push(row.txid.clone());
1176 }
1177 }
1178 Ok(PostAnswer::Refused { said }) => {
1179 row.reannounce_at = None;
1180 row.next_ask_at = pause;
1181 report.faults.push(format!(
1182 "{}: refused when announced again: {said}",
1183 row.txid
1184 ));
1185 }
1186 Err(e) => {
1187 row.reannounce_at = pause;
1189 report
1190 .faults
1191 .push(format!("{}: could not announce again: {e}", row.txid));
1192 }
1193 }
1194 }
1195 for state in states.values_mut() {
1196 let _ = state.step(¶ms, &snapshot, Input::Host(HostAction::Tick(now)));
1197 }
1198
1199 let due: Vec<TxId> = rows
1201 .iter()
1202 .filter(|row| {
1203 states[&row.txid].reask().is_some()
1204 && row.asks < MAX_ASKS_PER_TX
1205 && row.next_ask_at.is_none_or(|at| at <= now)
1206 })
1207 .take(opts.max_asks)
1208 .map(|row| row.txid.clone())
1209 .collect();
1210 let mut courier = Courier::carry(services, &due).await;
1211 snapshot.extend(services, courier.heights()).await;
1212
1213 for txid in &due {
1215 let (Some(state), Some(row)) = (
1216 states.get_mut(txid),
1217 rows.iter_mut().find(|r| &r.txid == txid),
1218 ) else {
1219 continue;
1220 };
1221 report.asked += 1;
1222 let reask = state.reask().cloned().unwrap_or(Reask::Age);
1223 let fetched = courier.fetch(txid, &reask);
1224 let proven = match fetched {
1225 Ok(Some(proof)) => {
1226 let hex = proof.path().to_hex();
1227 match state.step(¶ms, &snapshot, Input::Evidence(Evidence::Proof(proof))) {
1228 Ok(()) => {
1229 row.bump = Some(hex);
1230 row.chain.clear();
1231 true
1232 }
1233 Err(fault) => {
1234 report.faults.push(format!("{txid}: {fault}"));
1235 false
1236 }
1237 }
1238 }
1239 Ok(None) => {
1240 report.no_proof.push(txid.clone());
1241 false
1242 }
1243 Err(fault) => {
1244 report
1245 .faults
1246 .push(format!("{txid}: could not ask for a proof: {fault}"));
1247 false
1248 }
1249 };
1250 if proven {
1251 row.asks = 0;
1252 row.next_ask_at = None;
1253 report.mined.push(txid.clone());
1254 } else {
1255 let pause = opts
1256 .age_threshold
1257 .saturating_mul(1 << row.asks.min(MAX_BACKOFF_DOUBLINGS));
1258 row.asks = row.asks.saturating_add(1);
1259 row.next_ask_at = Some(now.saturating_add(pause));
1260 }
1261 }
1262
1263 for row in &rows {
1266 let state = &states[&row.txid];
1267 store_row(storage, row, state, now).await?;
1268 if let (true, Word::Mined(mined)) = (report.mined.contains(&row.txid), state.word()) {
1269 let checked = mined.checked();
1270 let outcome = storage
1271 .ingest_merkle_proof(
1272 &row.txid,
1273 &checked.proof().path().to_binary(),
1274 checked.height(),
1275 &checked.header().hash,
1276 Some(checked.root()),
1277 )
1278 .await;
1279 report.stored.push(match outcome {
1280 Ok(outcome) => format!("{}: {outcome:?}", row.txid),
1281 Err(e) => format!("{}: not stored: {e}", row.txid),
1282 });
1283 }
1284 }
1285 adopt_proven(storage, services, &mut snapshot, ¶ms, now, &mut report).await?;
1286 write_ring(storage, &ring).await?;
1287 Ok(report)
1288}
1289
1290pub const MAX_ADOPT_PER_PASS: usize = 100;
1292
1293async fn adopt_proven<V: WalletServices + ?Sized>(
1311 storage: &StorageSqlx,
1312 services: &V,
1313 snapshot: &mut HeaderSnapshot,
1314 params: &Params,
1315 now: Timestamp,
1316 report: &mut TickReport,
1317) -> Result<()> {
1318 let found = sqlx::query(
1319 "SELECT lower(t.txid) AS txid, MAX(t.is_outgoing) AS made, p.merkle_path \
1320 FROM transactions t \
1321 JOIN proven_txs p ON p.proven_tx_id = t.proven_tx_id \
1322 WHERE t.txid IS NOT NULL AND t.status = 'completed' \
1323 AND NOT EXISTS (SELECT 1 FROM tracker_states s WHERE s.txid = lower(t.txid)) \
1324 AND EXISTS (SELECT 1 FROM outputs o \
1325 WHERE o.transaction_id = t.transaction_id AND o.spent_by IS NULL) \
1326 GROUP BY lower(t.txid) ORDER BY MIN(t.transaction_id) LIMIT ?",
1327 )
1328 .bind(MAX_ADOPT_PER_PASS as i64)
1329 .fetch_all(storage.pool())
1330 .await?;
1331 let mut rows = Vec::with_capacity(found.len());
1332 for r in &found {
1333 let txid: String = r.get("txid");
1334 let path: Option<Vec<u8>> = r.get("merkle_path");
1335 let Some(path) = path
1336 .as_deref()
1337 .and_then(|bytes| MerklePath::from_binary(bytes).ok())
1338 else {
1339 report.faults.push(format!(
1340 "{txid}: the stored proof does not parse; not adopted"
1341 ));
1342 continue;
1343 };
1344 rows.push(TrackedRow {
1345 built: r.get::<i64, _>("made") != 0,
1346 bump: Some(path.to_hex()),
1347 ..TrackedRow::new(&txid)
1348 });
1349 }
1350 snapshot
1351 .extend(services, rows.iter().filter_map(TrackedRow::proof_height))
1352 .await;
1353 for row in rows {
1354 let (state, fault) = row.replay(params, &*snapshot);
1355 match fault {
1356 None if matches!(state.word(), Word::Mined(_)) => {
1357 report.adopted_mined.push(row.txid.clone());
1358 }
1359 Some(CheckError::RootMismatch(_)) => {
1360 tracing::warn!(
1361 marker = "tracker_adopted_proof_failed",
1362 txid = %row.txid,
1363 reask = ?state.reask(),
1364 "a proven creator's stored merkle path does not meet the active header; tracked with its re-ask"
1365 );
1366 report.adopted_proof_failed.push(row.txid.clone());
1367 }
1368 Some(fault) => {
1370 report
1371 .faults
1372 .push(format!("{}: not adopted: {fault}", row.txid));
1373 continue;
1374 }
1375 None => {
1376 report.faults.push(format!(
1377 "{}: not adopted: the stored proof gave the word {}",
1378 row.txid,
1379 word_label(state.word())
1380 ));
1381 continue;
1382 }
1383 }
1384 store_row(storage, &row, &state, now).await?;
1385 }
1386 Ok(())
1387}
1388
1389pub async fn record_accepted<W: WalletInterface>(wallet: &W, report: &mut TickReport) {
1394 for (txid, args) in std::mem::take(&mut report.on_accept) {
1395 let line = match serde_json::from_str::<InternalizeActionArgs>(&args) {
1396 Ok(args) => match wallet.internalize_action(args, "bsv-wallet-cli").await {
1397 Ok(result) if result.accepted => format!("{txid}: recorded in the wallet"),
1398 Ok(_) => format!("{txid}: the wallet did not accept its own transaction"),
1399 Err(e) => format!(
1400 "{txid}: not recorded in the wallet ({e}); `bsv-wallet receive {txid}` records it once it is mined"
1401 ),
1402 },
1403 Err(e) => format!("{txid}: not recorded in the wallet (stored arguments: {e})"),
1404 };
1405 if !line.ends_with("recorded in the wallet") {
1406 tracing::warn!(marker = "own_transaction_not_recorded", "{line}");
1407 }
1408 report.stored.push(line);
1409 }
1410}
1411
1412fn readme_spend_guard<H: Headers>(
1421 state: &mut State,
1422 params: &Params,
1423 headers: &H,
1424) -> std::result::Result<bool, CheckError> {
1425 state.step(params, headers, Input::Host(HostAction::SpendAttempt))?;
1426 state.step(params, headers, Input::Evidence(Evidence::Recheck))?;
1427 Ok(matches!(state.word(), Word::Mined(_)) && !state.suspect())
1428}
1429
1430pub async fn spend_guard<V: WalletServices + ?Sized, C: Clock>(
1437 storage: &StorageSqlx,
1438 services: &V,
1439 clock: &C,
1440 opts: &TickOptions,
1441 txid: &str,
1442) -> Result<std::result::Result<bool, CheckError>> {
1443 ensure_tables(storage).await?;
1444 let Some(row) = load_row(storage, txid).await? else {
1445 return Ok(Ok(false));
1446 };
1447 let params = opts.params();
1448 let snapshot = HeaderSnapshot::take(services, row.proof_height()).await;
1449 let (mut state, fault) = row.replay(¶ms, &snapshot);
1450 match fault {
1451 Some(fault) if !matches!(fault, CheckError::RootMismatch(_)) => return Ok(Err(fault)),
1455 _ => {}
1456 }
1457 let split = under_split(state.word(), open_split_floor(storage).await?);
1458 let verdict = readme_spend_guard(&mut state, ¶ms, &snapshot).and_then(|offered| {
1459 Ok(offered && !split && deep_enough(&state, &snapshot, opts.spend_depth)?)
1460 });
1461 store_row(storage, &row, &state, clock.now()).await?;
1462 Ok(verdict)
1463}
1464
1465#[derive(Debug, Clone, PartialEq, Eq)]
1468pub struct CreatorReading {
1469 pub word: &'static str,
1471 pub depth: u64,
1474 pub suspect: bool,
1477 pub offered: bool,
1480 pub fault: Option<String>,
1483}
1484
1485pub async fn read_creators<V: WalletServices + ?Sized>(
1494 storage: &StorageSqlx,
1495 services: &V,
1496 opts: &TickOptions,
1497 creators: &[(String, Option<Vec<u8>>)],
1498) -> Result<HashMap<String, CreatorReading>> {
1499 ensure_tables(storage).await?;
1500 let params = opts.params();
1501 let mut rows = Vec::with_capacity(creators.len());
1502 for (txid, stored_path) in creators {
1503 let mut row = match load_row(storage, txid).await? {
1504 Some(row) => row,
1505 None => TrackedRow {
1506 built: false,
1507 ..TrackedRow::new(txid)
1508 },
1509 };
1510 if row.bump.is_none() {
1511 row.bump = stored_path
1512 .as_deref()
1513 .and_then(|bytes| MerklePath::from_binary(bytes).ok())
1514 .map(|path| path.to_hex());
1515 }
1516 rows.push(row);
1517 }
1518 let snapshot =
1519 HeaderSnapshot::take(services, rows.iter().filter_map(TrackedRow::proof_height)).await;
1520 let split_floor = open_split_floor(storage).await?;
1521 let mut readings = HashMap::with_capacity(rows.len());
1522 for row in rows {
1523 let (mut state, fault) = row.replay(¶ms, &snapshot);
1524 let fault = match fault {
1525 Some(fault) if !matches!(fault, CheckError::RootMismatch(_)) => Some(fault.to_string()),
1526 _ => None,
1527 };
1528 let verdict = match fault {
1529 Some(_) => Ok(false),
1530 None => readme_spend_guard(&mut state, ¶ms, &snapshot).and_then(|offered| {
1531 Ok(offered && deep_enough(&state, &snapshot, opts.spend_depth)?)
1532 }),
1533 };
1534 let depth = match (state.word().height(), snapshot.tip()) {
1535 (Some(height), Some((tip, _))) => {
1536 (u64::from(tip) + 1).saturating_sub(u64::from(height))
1537 }
1538 _ => 0,
1539 };
1540 let (offered, fault) = match verdict {
1541 Ok(offered) => (offered, fault),
1542 Err(e) => (false, fault.or(Some(e.to_string()))),
1543 };
1544 let split = under_split(state.word(), split_floor);
1545 readings.insert(
1546 row.txid.clone(),
1547 CreatorReading {
1548 word: word_label(state.word()),
1549 depth,
1550 suspect: state.suspect() || split,
1551 offered: offered && !split,
1552 fault,
1553 },
1554 );
1555 }
1556 Ok(readings)
1557}
1558
1559fn deep_enough<H: Headers>(
1564 state: &State,
1565 headers: &H,
1566 spend_depth: Height,
1567) -> std::result::Result<bool, CheckError> {
1568 let Some(height) = state.word().height() else {
1569 return Ok(false);
1570 };
1571 let tip = headers.tip_height().map_err(CheckError::Headers)?;
1572 let depth = (u64::from(tip) + 1).saturating_sub(u64::from(height));
1573 Ok(depth >= u64::from(spend_depth.max(DEFAULT_SPEND_DEPTH)))
1574}
1575
1576async fn read_open_splits<'e, E: sqlx::Executor<'e, Database = sqlx::Sqlite>>(
1584 executor: E,
1585) -> Result<BTreeMap<String, Height>> {
1586 let value: Option<String> =
1587 sqlx::query_scalar("SELECT value FROM tracker_meta WHERE key = 'open_splits'")
1588 .fetch_optional(executor)
1589 .await?;
1590 Ok(match value {
1591 Some(v) => serde_json::from_str(&v)?,
1592 None => BTreeMap::new(),
1593 })
1594}
1595
1596pub async fn open_split_floor(storage: &StorageSqlx) -> Result<Option<Height>> {
1602 ensure_tables(storage).await?;
1603 Ok(read_open_splits(storage.pool()).await?.into_values().min())
1604}
1605
1606fn under_split(word: &Word, floor: Option<Height>) -> bool {
1608 matches!((word, floor), (Word::Mined(m), Some(floor)) if floor <= m.height())
1609}
1610
1611pub async fn feed_cursor(storage: &StorageSqlx) -> Result<u64> {
1614 ensure_tables(storage).await?;
1615 let value: Option<String> =
1616 sqlx::query_scalar("SELECT value FROM tracker_meta WHERE key = 'feed_cursor'")
1617 .fetch_optional(storage.pool())
1618 .await?;
1619 Ok(match value {
1620 Some(v) => v
1621 .parse()
1622 .map_err(|e| anyhow::anyhow!("the stored feed cursor {v:?}: {e}"))?,
1623 None => 0,
1624 })
1625}
1626
1627async fn ensure_frozen(pool: &sqlx::SqlitePool) -> Result<()> {
1631 sqlx::query(
1632 "CREATE TABLE IF NOT EXISTS frozen_outpoints (\
1633 txid TEXT NOT NULL, \
1634 vout INTEGER NOT NULL, \
1635 cursor INTEGER NOT NULL, \
1636 frozen_at INTEGER NOT NULL, \
1637 PRIMARY KEY (txid, vout))",
1638 )
1639 .execute(pool)
1640 .await?;
1641 Ok(())
1642}
1643
1644pub async fn frozen_outpoints(
1646 pool: &sqlx::SqlitePool,
1647) -> Result<std::collections::HashSet<(String, i64)>> {
1648 ensure_frozen(pool).await?;
1649 let rows = sqlx::query("SELECT txid, vout FROM frozen_outpoints")
1650 .fetch_all(pool)
1651 .await?;
1652 Ok(rows
1653 .iter()
1654 .map(|r| (r.get::<String, _>("txid"), r.get::<i64, _>("vout")))
1655 .collect())
1656}
1657
1658pub async fn frozen_count(pool: &sqlx::SqlitePool) -> Result<u64> {
1660 ensure_frozen(pool).await?;
1661 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM frozen_outpoints")
1662 .fetch_one(pool)
1663 .await?;
1664 Ok(n.max(0) as u64)
1665}
1666
1667#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1669pub struct ChainMove {
1670 pub txid: String,
1672 pub word: String,
1674 pub reask: Option<String>,
1676 pub suspect: bool,
1678 pub fault: Option<String>,
1680}
1681
1682#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1684pub struct ChainDelivery {
1685 pub cursor: u64,
1687 pub kind: &'static str,
1689 pub moved: Vec<ChainMove>,
1691}
1692
1693pub fn event_kind(event: &ChainEvent) -> &'static str {
1695 match event {
1696 ChainEvent::Tip { .. } => "tip",
1697 ChainEvent::Fork { .. } => "fork",
1698 ChainEvent::Reorg { .. } => "reorg",
1699 ChainEvent::Invalidated { .. } => "invalidated",
1700 ChainEvent::Frozen { .. } => "frozen",
1701 ChainEvent::TipAge { .. } => "tipAge",
1702 ChainEvent::Split { .. } => "split",
1703 ChainEvent::SplitResolved { .. } => "splitResolved",
1704 }
1705}
1706
1707struct Replaced<'a> {
1714 snapshot: &'a HeaderSnapshot,
1715 replaced: BTreeMap<Height, Header>,
1716 prior_tip: Option<Height>,
1717}
1718
1719impl Headers for Replaced<'_> {
1720 fn header_at(&self, height: Height) -> std::result::Result<Option<Header>, HeaderError> {
1721 match self.replaced.get(&height) {
1722 Some(header) => Ok(Some(header.clone())),
1723 None => self.snapshot.header_at(height),
1724 }
1725 }
1726
1727 fn tip_height(&self) -> std::result::Result<Height, HeaderError> {
1728 match self.prior_tip {
1729 Some(tip) => Ok(tip),
1730 None => self.snapshot.tip_height(),
1731 }
1732 }
1733}
1734
1735pub async fn deliver_chain_event<V: WalletServices + ?Sized, C: Clock>(
1749 storage: &StorageSqlx,
1750 services: &V,
1751 clock: &C,
1752 opts: &TickOptions,
1753 cursor: u64,
1754 event: ChainEvent,
1755) -> Result<ChainDelivery> {
1756 let kind = event_kind(&event);
1757 if cursor <= feed_cursor(storage).await? {
1758 return Ok(ChainDelivery {
1759 cursor,
1760 kind,
1761 moved: vec![],
1762 });
1763 }
1764 let named = format!("SELECT {ROW_COLUMNS} FROM tracker_states");
1765 let query = match &event {
1766 ChainEvent::Fork { height: from, .. }
1767 | ChainEvent::Reorg {
1768 fork_height: from, ..
1769 }
1770 | ChainEvent::Split {
1771 fork_height: from, ..
1772 }
1773 | ChainEvent::SplitResolved {
1774 fork_height: from, ..
1775 } => Some(
1776 sqlx::query(&format!(
1777 "{named} WHERE word = 'mined' AND height >= ? ORDER BY txid"
1778 ))
1779 .bind(i64::from(*from))
1780 .fetch_all(storage.pool())
1781 .await?,
1782 ),
1783 ChainEvent::Tip { .. } => Some(
1784 sqlx::query(&format!(
1785 "{named} WHERE word = 'mined' AND chain <> '[]' ORDER BY txid"
1786 ))
1787 .fetch_all(storage.pool())
1788 .await?,
1789 ),
1790 ChainEvent::Invalidated { block_hash } => Some(
1791 sqlx::query(&format!("{named} WHERE header_hash = ? ORDER BY txid"))
1792 .bind(block_hash.to_ascii_lowercase())
1793 .fetch_all(storage.pool())
1794 .await?,
1795 ),
1796 ChainEvent::Frozen { .. } | ChainEvent::TipAge { .. } => None,
1797 };
1798 let mut rows: Vec<TrackedRow> = query.iter().flatten().map(row_of).collect();
1799
1800 let now = clock.now();
1801 let mut moved = Vec::new();
1802 if matches!(event, ChainEvent::Frozen { .. }) {
1803 ensure_frozen(storage.pool()).await?;
1804 }
1805 let mut tx = storage.pool().begin().await?;
1806 if let ChainEvent::Frozen { outpoint } = &event {
1807 sqlx::query(
1808 "INSERT INTO frozen_outpoints (txid, vout, cursor, frozen_at) VALUES (?, ?, ?, ?) \
1809 ON CONFLICT(txid, vout) DO NOTHING",
1810 )
1811 .bind(outpoint.txid.to_ascii_lowercase())
1812 .bind(i64::from(outpoint.vout))
1813 .bind(cursor as i64)
1814 .bind(now as i64)
1815 .execute(&mut *tx)
1816 .await?;
1817 }
1818 if !rows.is_empty() {
1819 let snapshot =
1820 HeaderSnapshot::take(services, rows.iter().filter_map(TrackedRow::proof_height)).await;
1821 let (replaced, prior_tip) = match &event {
1822 ChainEvent::Reorg {
1823 deactivated_headers,
1824 ..
1825 } => (
1826 deactivated_headers
1827 .iter()
1828 .map(|h| {
1829 (
1830 h.height,
1831 Header {
1832 hash: h.hash.to_ascii_lowercase(),
1833 merkle_root: h.merkle_root.to_ascii_lowercase(),
1834 },
1835 )
1836 })
1837 .collect(),
1838 deactivated_headers.first().map(|h| h.height),
1839 ),
1840 _ => (BTreeMap::new(), None),
1841 };
1842 let replay_view = Replaced {
1843 snapshot: &snapshot,
1844 replaced,
1845 prior_tip,
1846 };
1847 let mut tracker = Tracker::new(opts.params());
1848 for row in &rows {
1849 row.replay_into(&mut tracker, &replay_view);
1850 }
1851 for update in tracker.on_chain(&snapshot, event.clone()) {
1852 let (Some(state), Some(row)) = (
1853 tracker.get(&update.txid),
1854 rows.iter_mut().find(|r| r.txid == update.txid),
1855 ) else {
1856 continue;
1857 };
1858 row.keep_chain(state, &event)?;
1859 if matches!(update.reask, Some(Reask::Reorg { .. })) {
1860 row.asks = 0;
1862 row.next_ask_at = None;
1863 }
1864 store_row_in(&mut *tx, row, state, now).await?;
1865 let fault = update.result.err().map(|e| e.to_string());
1866 if let Some(fault) = &fault {
1867 tracing::warn!(txid = %update.txid, kind, cursor, %fault, "a chain event's recheck could not look");
1868 }
1869 moved.push(ChainMove {
1870 txid: update.txid,
1871 word: word_label(state.word()).to_string(),
1872 reask: state.reask().map(reask_label),
1873 suspect: state.suspect(),
1874 fault,
1875 });
1876 }
1877 }
1878 if let ChainEvent::Split {
1879 key, fork_height, ..
1880 }
1881 | ChainEvent::SplitResolved {
1882 key, fork_height, ..
1883 } = &event
1884 {
1885 let mut open = read_open_splits(&mut *tx).await?;
1886 if matches!(event, ChainEvent::Split { .. }) {
1887 open.insert(key.clone(), *fork_height);
1888 } else {
1889 open.remove(key);
1890 }
1891 sqlx::query(
1892 "INSERT INTO tracker_meta (key, value) VALUES ('open_splits', ?) \
1893 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
1894 )
1895 .bind(serde_json::to_string(&open)?)
1896 .execute(&mut *tx)
1897 .await?;
1898 }
1899 sqlx::query(
1900 "INSERT INTO tracker_meta (key, value) VALUES ('feed_cursor', ?) \
1901 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
1902 )
1903 .bind(cursor.to_string())
1904 .execute(&mut *tx)
1905 .await?;
1906 tx.commit().await?;
1907 Ok(ChainDelivery {
1908 cursor,
1909 kind,
1910 moved,
1911 })
1912}
1913
1914#[cfg(test)]
1915pub(crate) mod tests {
1916 use super::*;
1917 use bsv_wallet_toolbox::services::mock::{MockResponse, MockWalletServices};
1918 use bsv_wallet_toolbox::services::{BlockHeader, GetMerklePathResult};
1919 use bsv_wallet_toolbox::{
1920 WalletStorageWriter, BROADCAST_STATUS_ACCEPTED, BROADCAST_STATUS_MINED,
1921 };
1922 use chrono::Utc;
1923
1924 pub(crate) struct FixedClock(pub Timestamp);
1926 impl Clock for FixedClock {
1927 fn now(&self) -> Timestamp {
1928 self.0
1929 }
1930 }
1931
1932 pub(crate) fn opts() -> TickOptions {
1933 TickOptions {
1934 age_threshold: 600,
1935 max_asks: 20,
1936 spend_depth: DEFAULT_SPEND_DEPTH,
1937 }
1938 }
1939
1940 pub(crate) async fn wallet_storage() -> (StorageSqlx, i64) {
1942 let storage = StorageSqlx::in_memory().await.unwrap();
1943 storage
1944 .migrate("tracker-tests", &("02".to_string() + &"ab".repeat(32)))
1945 .await
1946 .unwrap();
1947 storage.make_available().await.unwrap();
1948 let (user, _) = storage
1949 .find_or_insert_user(&("02".to_string() + &"cd".repeat(32)))
1950 .await
1951 .unwrap();
1952 (storage, user.user_id)
1953 }
1954
1955 pub(crate) async fn insert_tx(storage: &StorageSqlx, user_id: i64, txid: &str, status: &str) {
1956 sqlx::query(
1957 "INSERT INTO transactions (user_id, status, reference, is_outgoing, satoshis, version, lock_time, description, txid, raw_tx, created_at, updated_at) \
1958 VALUES (?, ?, ?, 1, 0, 1, 0, 'd', ?, X'01000000', ?, ?)",
1959 )
1960 .bind(user_id)
1961 .bind(status)
1962 .bind(&txid[..8])
1963 .bind(txid)
1964 .bind(Utc::now())
1965 .bind(Utc::now())
1966 .execute(storage.pool())
1967 .await
1968 .unwrap();
1969 }
1970
1971 pub(crate) async fn proven_coin(
1977 storage: &StorageSqlx,
1978 user_id: i64,
1979 txid: &str,
1980 height: u32,
1981 satoshis: i64,
1982 ) {
1983 let basket = storage
1984 .find_or_create_default_basket(user_id)
1985 .await
1986 .unwrap()
1987 .basket_id;
1988 let proven = sqlx::query(
1989 "INSERT INTO proven_txs (txid, height, idx, block_hash, merkle_root, merkle_path, raw_tx, created_at, updated_at) \
1990 VALUES (?, ?, 0, ?, ?, ?, X'01000000', ?, ?)",
1991 )
1992 .bind(txid)
1993 .bind(height as i64)
1994 .bind("bb".repeat(32))
1995 .bind(txid)
1996 .bind(MerklePath::from_coinbase_txid(txid, height).to_binary())
1997 .bind(Utc::now())
1998 .bind(Utc::now())
1999 .execute(storage.pool())
2000 .await
2001 .unwrap()
2002 .last_insert_rowid();
2003 let tx = sqlx::query(
2004 "INSERT INTO transactions (user_id, status, reference, is_outgoing, satoshis, version, lock_time, description, txid, raw_tx, proven_tx_id, created_at, updated_at) \
2005 VALUES (?, 'completed', ?, 0, ?, 1, 0, 'Internalize external funding', ?, X'01000000', ?, ?, ?)",
2006 )
2007 .bind(user_id)
2008 .bind(&txid[..8])
2009 .bind(satoshis)
2010 .bind(txid)
2011 .bind(proven)
2012 .bind(Utc::now())
2013 .bind(Utc::now())
2014 .execute(storage.pool())
2015 .await
2016 .unwrap()
2017 .last_insert_rowid();
2018 sqlx::query(
2019 "INSERT INTO outputs (user_id, transaction_id, basket_id, vout, satoshis, locking_script, txid, type, spendable, change, provided_by, purpose, output_description, created_at, updated_at) \
2020 VALUES (?, ?, ?, 0, ?, X'76a914dbc0a7c84983c5bf199b7b2d41b3acf0408ee5aa88ac', ?, 'P2PKH', 1, 1, 'storage', 'change', 'c', ?, ?)",
2021 )
2022 .bind(user_id)
2023 .bind(tx)
2024 .bind(basket)
2025 .bind(satoshis)
2026 .bind(txid)
2027 .bind(Utc::now())
2028 .bind(Utc::now())
2029 .execute(storage.pool())
2030 .await
2031 .unwrap();
2032 }
2033
2034 pub(crate) fn header(height: u32, root: &str, nonce: u32) -> BlockHeader {
2036 BlockHeader {
2037 version: 1,
2038 previous_hash: "00".repeat(32),
2039 merkle_root: root.to_string(),
2040 time: 1_700_000_000,
2041 bits: 0x1d00_ffff,
2042 nonce,
2043 hash: String::new(),
2044 height,
2045 }
2046 }
2047
2048 pub(crate) fn path_of(txid: &str, height: u32) -> String {
2051 MerklePath::from_coinbase_txid(txid, height).to_hex()
2052 }
2053
2054 pub(crate) fn proof_answer(path: Option<String>) -> MockResponse<GetMerklePathResult> {
2055 MockResponse::Success(GetMerklePathResult {
2056 name: Some("Services".to_string()),
2057 merkle_path: path,
2058 header: None,
2059 error: None,
2060 notes: vec![],
2061 })
2062 }
2063
2064 #[tokio::test]
2071 async fn a_pass_tracks_announces_and_proves_one_named_transaction() {
2072 let (storage, user) = wallet_storage().await;
2073 let txid = "a1".repeat(32);
2074 insert_tx(&storage, user, &txid, "unproven").await;
2075 storage
2076 .record_broadcast_status(&txid, "arcade", BROADCAST_STATUS_ACCEPTED)
2077 .await
2078 .unwrap();
2079 let services = MockWalletServices::builder()
2080 .get_merkle_path_response(proof_answer(Some(path_of(&txid, 900))))
2081 .build();
2082 services.set_header_for_height(header(900, &txid, 0));
2083 let now = Utc::now().timestamp() as u64;
2084
2085 let report = tick(&storage, &services, &FixedClock(now), &opts())
2087 .await
2088 .unwrap();
2089 assert_eq!((report.adopted, report.hints, report.asked), (1, 1, 0));
2090 assert_eq!(
2091 stored_word(&storage, &txid).await.unwrap(),
2092 Some(("announced".to_string(), None, None))
2093 );
2094 assert_eq!(services.call_count("get_merkle_path"), 0);
2095
2096 let report = tick(&storage, &services, &FixedClock(now + 601), &opts())
2098 .await
2099 .unwrap();
2100 assert_eq!(report.asked, 1);
2101 assert_eq!(report.mined, vec![txid.clone()]);
2102 assert_eq!(
2103 stored_word(&storage, &txid).await.unwrap(),
2104 Some(("mined".to_string(), Some(900), None))
2105 );
2106 assert_eq!(services.call_count("get_merkle_path"), 1);
2107
2108 let report = tick(&storage, &services, &FixedClock(now + 1300), &opts())
2110 .await
2111 .unwrap();
2112 assert_eq!((report.tracked, report.asked), (0, 0));
2113 assert_eq!(services.call_count("get_merkle_path"), 1);
2114 }
2115
2116 #[tokio::test]
2120 async fn a_broadcasters_mined_is_a_hint_that_asks_for_the_proof() {
2121 let (storage, user) = wallet_storage().await;
2122 let txid = "b2".repeat(32);
2123 insert_tx(&storage, user, &txid, "unproven").await;
2124 storage
2125 .record_broadcast_status(&txid, "arcade", BROADCAST_STATUS_MINED)
2126 .await
2127 .unwrap();
2128 let services = MockWalletServices::builder()
2129 .get_merkle_path_response(proof_answer(None))
2130 .build();
2131 let now = Utc::now().timestamp() as u64;
2132
2133 let report = tick(&storage, &services, &FixedClock(now), &opts())
2134 .await
2135 .unwrap();
2136 assert_eq!(report.asked, 1);
2137 assert_eq!(report.no_proof, vec![txid.clone()]);
2138 assert!(report.mined.is_empty());
2139 let (word, height, reask) = stored_word(&storage, &txid).await.unwrap().unwrap();
2140 assert_eq!((word.as_str(), height), ("built", None));
2141 assert_eq!(reask.as_deref(), Some("hint:arcade|mined"));
2142
2143 let report = tick(&storage, &services, &FixedClock(now + 60), &opts())
2145 .await
2146 .unwrap();
2147 assert_eq!(report.asked, 0);
2148 let report = tick(&storage, &services, &FixedClock(now + 600), &opts())
2150 .await
2151 .unwrap();
2152 assert_eq!(report.asked, 1);
2153 let report = tick(&storage, &services, &FixedClock(now + 1200), &opts())
2154 .await
2155 .unwrap();
2156 assert_eq!(report.asked, 0);
2157 assert_eq!(services.call_count("get_merkle_path"), 2);
2158 }
2159
2160 #[tokio::test]
2163 async fn a_moved_header_reasks_the_rows_at_or_above_it_and_no_other() {
2164 let (storage, user) = wallet_storage().await;
2165 let low = "c3".repeat(32);
2166 let high = "d4".repeat(32);
2167 let services = MockWalletServices::builder()
2168 .get_merkle_path_response(MockResponse::Sequence(vec![
2169 proof_answer(Some(path_of(&low, 100))),
2170 proof_answer(Some(path_of(&high, 200))),
2171 proof_answer(Some(path_of(&high, 201))),
2172 ]))
2173 .build();
2174 services.set_header_for_height(header(100, &low, 0));
2175 services.set_header_for_height(header(200, &high, 0));
2176 services.set_tip_header(header_with_hash(200, &high, 0));
2177 let now = Utc::now().timestamp() as u64;
2178 for txid in [&low, &high] {
2179 insert_tx(&storage, user, txid, "unproven").await;
2180 storage
2181 .record_broadcast_status(txid, "arcade", BROADCAST_STATUS_MINED)
2182 .await
2183 .unwrap();
2184 }
2185 let report = tick(&storage, &services, &FixedClock(now), &opts())
2186 .await
2187 .unwrap();
2188 assert_eq!(report.mined.len(), 2, "{report:?}");
2189
2190 services.set_header_for_height(header(200, &"ee".repeat(32), 7));
2193 services.set_header_for_height(header(201, &high, 0));
2194 services.set_tip_header(header_with_hash(201, &high, 0));
2195 let report = tick(&storage, &services, &FixedClock(now + 60), &opts())
2196 .await
2197 .unwrap();
2198 assert_eq!(report.moved_from, Some(200));
2199 assert_eq!(
2200 report.tracked, 1,
2201 "only the row at or above the moved header"
2202 );
2203 assert_eq!(report.moved, vec![high.clone()]);
2204 assert_eq!(report.mined, vec![high.clone()]);
2205 assert_eq!(
2206 stored_word(&storage, &high).await.unwrap().unwrap().1,
2207 Some(201)
2208 );
2209 assert_eq!(
2210 stored_word(&storage, &low).await.unwrap().unwrap().1,
2211 Some(100)
2212 );
2213 }
2214
2215 #[tokio::test]
2221 async fn a_not_final_transaction_is_announced_again_at_its_lock_time() {
2222 use bsv_wallet_toolbox::services::mock::{
2223 error_post_beef_result, success_post_beef_result,
2224 };
2225 let (storage, _user) = wallet_storage().await;
2226 let txid = "c7".repeat(32);
2227 let lock = 2_000_000_000u64;
2228 let services = MockWalletServices::builder()
2229 .post_beef_response(MockResponse::Sequence(vec![
2230 MockResponse::Success(vec![error_post_beef_result("Arcade", "476 non-final")]),
2231 MockResponse::Success(vec![success_post_beef_result("Arcade", &[&txid])]),
2232 ]))
2233 .get_merkle_path_response(proof_answer(None))
2234 .build();
2235
2236 let hint = Hint::new(
2237 "Arcade|rejected",
2238 HintStatus::Rejected {
2239 reason: "476 non-final".to_string(),
2240 },
2241 lock - 3600,
2242 );
2243 let again = Reannounce {
2244 beef: vec![1, 2, 3],
2245 at: lock,
2246 on_accept: None,
2247 };
2248 let word = heard(
2249 &storage,
2250 &FixedClock(lock - 3600),
2251 &opts(),
2252 &txid,
2253 hint,
2254 Some(lock),
2255 Some(again),
2256 )
2257 .await
2258 .unwrap();
2259 assert_eq!(
2260 word,
2261 (
2262 "built".to_string(),
2263 Some("hint:Arcade|rejected".to_string())
2264 )
2265 );
2266
2267 let report = tick(&storage, &services, &FixedClock(lock - 1), &opts())
2269 .await
2270 .unwrap();
2271 assert_eq!((report.tracked, report.asked), (1, 0), "{report:?}");
2272 assert_eq!(services.call_count("post_beef"), 0);
2273
2274 let report = tick(&storage, &services, &FixedClock(lock), &opts())
2276 .await
2277 .unwrap();
2278 assert_eq!(report.not_final, vec![txid.clone()]);
2279 assert_eq!(services.call_count("post_beef"), 1);
2280 let report = tick(&storage, &services, &FixedClock(lock + 60), &opts())
2281 .await
2282 .unwrap();
2283 assert!(report.not_final.is_empty() && report.reannounced.is_empty());
2284 assert_eq!(services.call_count("post_beef"), 1, "one pause between two");
2285
2286 let report = tick(&storage, &services, &FixedClock(lock + 600), &opts())
2288 .await
2289 .unwrap();
2290 assert_eq!(report.reannounced, vec![txid.clone()]);
2291 assert_eq!(services.call_count("post_beef"), 2);
2292 assert_eq!(
2293 stored_word(&storage, &txid).await.unwrap().unwrap().0,
2294 "announced"
2295 );
2296 assert_eq!(services.call_count("get_merkle_path"), 0);
2297
2298 let report = tick(&storage, &services, &FixedClock(lock + 1300), &opts())
2300 .await
2301 .unwrap();
2302 assert_eq!(report.asked, 1);
2303 assert_eq!(services.call_count("post_beef"), 2);
2304 assert_eq!(services.call_count("get_merkle_path"), 1);
2305 }
2306
2307 #[test]
2310 fn a_mempool_presence_hint_round_trips_through_the_stored_row() {
2311 let hint = Hint::new(
2312 "arcade".to_string(),
2313 HintStatus::MempoolPresent {
2314 source: "courier-a".to_string(),
2315 },
2316 1_700_000_000,
2317 );
2318 let stored = StoredHint::of(&hint);
2319 let json = serde_json::to_string(&stored).unwrap();
2320 let back: StoredHint = serde_json::from_str(&json).unwrap();
2321 assert_eq!(back.hint(), hint);
2322
2323 let bare = StoredHint {
2324 courier: None,
2325 ..stored
2326 };
2327 assert_eq!(bare.hint().status, HintStatus::Unknown);
2328 }
2329
2330 #[test]
2332 fn not_final_is_read_from_the_refusal() {
2333 for said in [
2334 "476 non-final",
2335 "bad-txns-nonfinal",
2336 "tx is not final",
2337 "code 476",
2338 ] {
2339 assert!(says_not_final(said), "{said}");
2340 }
2341 for said in [
2342 "fee too low",
2343 "DOUBLE_SPEND_ATTEMPTED",
2344 "txid 4476aa",
2345 "HTTP 500",
2346 ] {
2347 assert!(!says_not_final(said), "{said}");
2348 }
2349 }
2350
2351 pub(crate) fn header_with_hash(height: u32, root: &str, nonce: u32) -> BlockHeader {
2353 let mut h = header(height, root, nonce);
2354 h.hash = header_of(&h.to_binary()).unwrap().hash;
2355 h
2356 }
2357
2358 #[tokio::test]
2363 async fn the_spend_depth_holds_a_coin_until_its_block_is_deep_enough() {
2364 let (storage, user) = wallet_storage().await;
2365 let txid = "e7".repeat(32);
2366 insert_tx(&storage, user, &txid, "unproven").await;
2367 storage
2368 .record_broadcast_status(&txid, "arcade", BROADCAST_STATUS_MINED)
2369 .await
2370 .unwrap();
2371 let services = MockWalletServices::builder()
2372 .get_merkle_path_response(proof_answer(Some(path_of(&txid, 900))))
2373 .build();
2374 services.set_header_for_height(header(900, &txid, 0));
2375 services.set_tip_header(header_with_hash(900, &txid, 0));
2376 let clock = FixedClock(Utc::now().timestamp() as u64);
2377 tick(&storage, &services, &clock, &opts()).await.unwrap();
2378 let at = |spend_depth| TickOptions {
2379 spend_depth,
2380 ..opts()
2381 };
2382
2383 let one = spend_guard(&storage, &services, &clock, &at(1), &txid)
2384 .await
2385 .unwrap();
2386 let three = spend_guard(&storage, &services, &clock, &at(3), &txid)
2387 .await
2388 .unwrap();
2389 let zero = spend_guard(&storage, &services, &clock, &at(0), &txid)
2390 .await
2391 .unwrap();
2392 println!("one block deep: depth 1 {one:?}, depth 3 {three:?}, depth 0 {zero:?}");
2393 assert_eq!(one, Ok(true));
2394 assert_eq!(three, Ok(false));
2395 assert_eq!(zero, Ok(true), "0 reads as the floor, 1");
2396
2397 services.set_tip_header(header_with_hash(902, &"0c".repeat(32), 0));
2398 let three = spend_guard(&storage, &services, &clock, &at(3), &txid)
2399 .await
2400 .unwrap();
2401 println!("three blocks deep: depth 3 {three:?}");
2402 assert_eq!(three, Ok(true));
2403 assert_eq!(
2404 TickOptions {
2405 spend_depth: 0,
2406 ..opts()
2407 }
2408 .floored()
2409 .spend_depth,
2410 1
2411 );
2412 }
2413
2414 #[tokio::test]
2419 async fn the_readme_spend_guard_runs_as_the_host() {
2420 let (storage, user) = wallet_storage().await;
2421 let txid = "e5".repeat(32);
2422 insert_tx(&storage, user, &txid, "unproven").await;
2423 storage
2424 .record_broadcast_status(&txid, "arcade", BROADCAST_STATUS_MINED)
2425 .await
2426 .unwrap();
2427 let services = MockWalletServices::builder()
2428 .get_merkle_path_response(proof_answer(Some(path_of(&txid, 900))))
2429 .build();
2430 services.set_header_for_height(header(900, &txid, 0));
2431 let clock = FixedClock(Utc::now().timestamp() as u64);
2432 tick(&storage, &services, &clock, &opts()).await.unwrap();
2433
2434 let guard = spend_guard(&storage, &services, &clock, &opts(), &txid)
2436 .await
2437 .unwrap();
2438 println!(
2439 "spend guard, header in place: {guard:?}, stored {:?}",
2440 stored_word(&storage, &txid).await.unwrap().unwrap()
2441 );
2442 assert_eq!(guard, Ok(true));
2443
2444 services.set_tip_unavailable();
2446 let guard = spend_guard(&storage, &services, &clock, &opts(), &txid)
2447 .await
2448 .unwrap();
2449 println!(
2450 "spend guard, no header service: {guard:?}, stored {:?}",
2451 stored_word(&storage, &txid).await.unwrap().unwrap()
2452 );
2453 assert!(matches!(guard, Err(CheckError::Headers(_))), "{guard:?}");
2454 assert_eq!(
2455 stored_word(&storage, &txid).await.unwrap().unwrap().0,
2456 "mined",
2457 "could not look changes no word"
2458 );
2459
2460 services.set_tip_header(header_with_hash(901, &"00".repeat(32), 1));
2462 services.set_header_for_height(header(900, &"ee".repeat(32), 7));
2463 let guard = spend_guard(&storage, &services, &clock, &opts(), &txid)
2464 .await
2465 .unwrap();
2466 println!(
2467 "spend guard, header moved: {guard:?}, stored {:?}",
2468 stored_word(&storage, &txid).await.unwrap().unwrap()
2469 );
2470 assert_eq!(guard, Ok(false));
2471
2472 let guard = spend_guard(&storage, &services, &clock, &opts(), &"f6".repeat(32))
2474 .await
2475 .unwrap();
2476 assert_eq!(guard, Ok(false));
2477
2478 let params = opts().params();
2481 services.set_tip_header(header_with_hash(900, &txid, 0));
2482 services.set_header_for_height(header(900, &txid, 0));
2483 let held = HeaderSnapshot::take(&services, [900]).await;
2484 let row = load_row(&storage, &txid).await.unwrap().unwrap();
2485 let (mut state, fault) = row.replay(¶ms, &held);
2486 assert_eq!(fault, None);
2487 let lost = HeaderSnapshot::take(&services, []).await;
2488 let guard = readme_spend_guard(&mut state, ¶ms, &lost);
2489 println!("spend guard, height unanswered: {guard:?}");
2490 assert_eq!(guard, Err(CheckError::Unavailable(900)));
2491 }
2492
2493 #[tokio::test]
2503 async fn the_served_pass_adopts_an_unproven_creator_and_a_proven_completed_one() {
2504 let (storage, user) = wallet_storage().await;
2505 let unproven = "d1".repeat(32);
2506 let earlier = "d2".repeat(32);
2507 insert_tx(&storage, user, &unproven, "unproven").await;
2508 insert_tx(&storage, user, &earlier, "unproven").await;
2509 let services = MockWalletServices::builder().build();
2510 services.set_tip_header(header_with_hash(960, &"0a".repeat(32), 0));
2511 let now = Utc::now().timestamp() as u64;
2512 let pass = crate::broadcast_reconcile::run_served_pass(
2513 &storage,
2514 &services,
2515 &FixedClock(now),
2516 &opts(),
2517 None,
2518 )
2519 .await
2520 .unwrap();
2521 println!("{}", pass.summary());
2522 assert_eq!(pass.tracker.adopted, 2);
2523 assert_eq!(
2524 stored_word(&storage, &earlier).await.unwrap(),
2525 Some(("built".to_string(), None, None))
2526 );
2527
2528 sqlx::query("UPDATE transactions SET status = 'completed' WHERE txid = ?")
2532 .bind(&earlier)
2533 .execute(storage.pool())
2534 .await
2535 .unwrap();
2536 let checks = "d3".repeat(32);
2537 let moved = "d4".repeat(32);
2538 proven_coin(&storage, user, &checks, 900, 3_000).await;
2539 proven_coin(&storage, user, &moved, 950, 2_000).await;
2540 services.set_header_for_height(header(900, &checks, 0));
2541 services.set_header_for_height(header(950, &"ee".repeat(32), 0));
2542 let toolbox_before: Vec<(String, String, Option<i64>)> =
2543 sqlx::query_as("SELECT txid, status, proven_tx_id FROM transactions ORDER BY txid")
2544 .fetch_all(storage.pool())
2545 .await
2546 .unwrap();
2547
2548 let pass = crate::broadcast_reconcile::run_served_pass(
2549 &storage,
2550 &services,
2551 &FixedClock(now + 60),
2552 &opts(),
2553 None,
2554 )
2555 .await
2556 .unwrap();
2557 println!("{}", pass.summary());
2558 assert_eq!(pass.tracker.adopted_mined, vec![checks.clone()]);
2559 assert_eq!(pass.tracker.adopted_proof_failed, vec![moved.clone()]);
2560 assert_eq!(
2561 stored_word(&storage, &checks).await.unwrap(),
2562 Some(("mined".to_string(), Some(900), None))
2563 );
2564 let row = load_row(&storage, &checks).await.unwrap().unwrap();
2565 assert_eq!(
2566 row.bump,
2567 Some(path_of(&checks, 900)),
2568 "the evidence is the stored path"
2569 );
2570 assert!(!row.built, "a received coin's creator was not built here");
2571 assert_eq!(
2572 stored_word(&storage, &moved).await.unwrap(),
2573 Some((
2574 "unknown".to_string(),
2575 None,
2576 Some("proof_failed@950".to_string())
2577 ))
2578 );
2579 assert_eq!(
2580 stored_word(&storage, &earlier).await.unwrap(),
2581 Some(("built".to_string(), None, None)),
2582 "a creator tracked while unproven keeps its row"
2583 );
2584 let toolbox_after: Vec<(String, String, Option<i64>)> =
2585 sqlx::query_as("SELECT txid, status, proven_tx_id FROM transactions ORDER BY txid")
2586 .fetch_all(storage.pool())
2587 .await
2588 .unwrap();
2589 assert_eq!(
2590 toolbox_after, toolbox_before,
2591 "no write to the toolbox's rows"
2592 );
2593 assert_eq!(services.call_count("get_merkle_path"), 0);
2594
2595 let pass = crate::broadcast_reconcile::run_served_pass(
2596 &storage,
2597 &services,
2598 &FixedClock(now + 120),
2599 &opts(),
2600 None,
2601 )
2602 .await
2603 .unwrap();
2604 println!("{}", pass.summary());
2605 assert!(pass.tracker.adopted_mined.is_empty());
2606 assert!(pass.tracker.adopted_proof_failed.is_empty());
2607 }
2608}