Skip to main content

bsv_wallet_cli/
tracker_host.rs

1//! The CLI as a host of the transaction tracker (`bsv-tracker`).
2//!
3//! The tracker holds one word per transaction, the evidence behind it, the
4//! hints it has heard and the next re-ask. Only headers and proofs change a
5//! word; every broadcaster word is a hint that schedules a re-ask. The crate
6//! fetches nothing, reads no clock and stores nothing: the host supplies the
7//! four things below, runs the re-asks, and keeps the state.
8//!
9//! # The four host things
10//!
11//! | the tracker's trait | here | what it is |
12//! |---|---|---|
13//! | `Headers` | [`HeaderSnapshot`] | the header service's tip and the headers at the heights a pass needs, read once per pass through the wallet's services; a height not read is "no answer", a fault is a fault |
14//! | `ProofFetcher` | [`Courier`] | `get_merkle_path` for one named transaction; what it carries is a raw path, checked by the tracker against the snapshot |
15//! | `HintSource` | [`MemoryHints`] | the broadcast memory rows of the wallet's storage (what a broadcaster answered, what Arcade pushed over SSE or the webhook), each word fed once |
16//! | `Clock` | [`SystemClock`] | seconds since the epoch |
17//!
18//! The tracker's traits are synchronous and the wallet's transports are
19//! not, so a pass reads first (headers, memory rows, proofs for the re-asks
20//! that are due) and steps second.
21//!
22//! # The state, in the CLI's storage
23//!
24//! One row per tracked transaction in `tracker_states`, in the wallet's own
25//! database. A row holds inputs, never a trusted word: whether the host
26//! built it, the hints in the order heard, and the raw merkle path. Loading
27//! a row replays those inputs, so a stored path is checked again against
28//! the active header every time it is read (the tracker has no
29//! deserializer for a checked proof, by design). The `word`, `height` and
30//! `reask` columns are what the tracker said at the last step, kept for
31//! reading and for the indexes; nothing is decided from them.
32//!
33//! # What a pass asks, and what it never asks
34//!
35//! A pass ([`tick`]) names transactions; it never walks the chain and
36//! never asks a chain index. It loads the rows that are not yet mined, feeds
37//! the new hints, ticks the clock, and runs the re-asks that are due: one
38//! proof request for one transaction, with a doubling pause between two
39//! requests for the same transaction. A mined row is read again only when
40//! the header at or below its height moved (the last twelve tips are kept
41//! to see that) or when a spend names it ([`spend_guard`]).
42//!
43//! # What the header service pushes
44//!
45//! The daemon's chain-event feed ([`crate::chain_feed`]) hands each
46//! envelope to [`deliver_chain_event`]: the rows the event names by index
47//! (the mined rows at or above a fork, reorg or split height, the rows
48//! marked by an earlier event for a tip, the rows on an invalidated
49//! block's hash) are loaded into a [`Tracker`], and `Tracker::on_chain`
50//! moves their words. An event that left a row anything but a plain
51//! checked `mined` is kept on the row as one more input to replay (the
52//! `chain` column), so a `stale` or a suspect mark survives the next load;
53//! a new proof, or a stored path dropped because it no longer checks,
54//! clears it. The cursor is stored in the same transaction.
55//!
56//! One re-ask is an announce, not a proof request: a transaction a
57//! broadcaster called not final (a forced `gift-claim` before its lock
58//! time) is kept with its BEEF and handed to the broadcasters again at the
59//! lock time ([`heard`], and the re-announce step of [`tick`]).
60
61use 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
77/// Default seconds without a new hint before an announced or seen
78/// transaction is asked for its proof (`TRACKER_AGE_SECS`): one block
79/// interval.
80pub const DEFAULT_AGE_SECS: u64 = 600;
81/// Default re-asks one pass runs (`TRACKER_MAX_ASKS`).
82pub const DEFAULT_MAX_ASKS: usize = 20;
83/// The pause between two re-asks of one transaction doubles up to this
84/// many times (600 s, then 1200 s, up to 64 times the age threshold).
85const MAX_BACKOFF_DOUBLINGS: u32 = 6;
86/// A transaction asked this many times in a row with no proof is not asked
87/// again until a new hint names it (about five days at the default age).
88const MAX_ASKS_PER_TX: u32 = 16;
89/// Recent tips kept to see a moved header.
90const TIP_RING: usize = 12;
91/// Default spend depth (`SPEND_DEPTH`), and its floor: a coin is offered
92/// when the block holding its transaction is at or below the tip.
93pub const DEFAULT_SPEND_DEPTH: Height = 1;
94
95// ---------------------------------------------------------------------------
96// Headers
97// ---------------------------------------------------------------------------
98
99/// One pass's view of the active chain: the tip and the headers at the
100/// heights the pass named, read from the header service through the
101/// wallet's services. Fails closed: a height that was not read has no
102/// answer, a read that failed is a fault, and with no tip nothing checks.
103#[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    /// Read the tip and the headers at `heights`.
111    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    /// A snapshot of nothing: no tip, no header. Every check fails closed.
129    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    /// Read the headers at `heights` that this snapshot does not hold yet.
137    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    /// The tip's height and hash, when the header service gave one.
155    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
177/// The tracker's header projection of an 80-byte block header: its hash
178/// and its merkle root, both as displayed.
179fn 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// ---------------------------------------------------------------------------
195// Clock
196// ---------------------------------------------------------------------------
197
198/// The host's clock: seconds since the epoch.
199#[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// ---------------------------------------------------------------------------
212// Hints
213// ---------------------------------------------------------------------------
214
215/// The hints of one pass: the broadcast memory rows of the tracked
216/// transactions whose `(provider, word)` the row has not heard yet. A word
217/// a broadcaster repeats every minute is one hint, so a refreshed `seen`
218/// never resets the age clock.
219#[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            // A broadcaster's "mined" carries no height here and is no
242            // proof: it reads as a word that disagrees and asks for one.
243            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// ---------------------------------------------------------------------------
275// Proofs
276// ---------------------------------------------------------------------------
277
278/// The host's proof transport: `get_merkle_path` for the transactions whose
279/// re-ask is due, read before the tracker steps. What it hands over is a
280/// raw path bound to its txid; the tracker checks it.
281#[derive(Debug, Default)]
282pub struct Courier {
283    carried: HashMap<TxId, std::result::Result<Option<Proof>, String>>,
284}
285
286impl Courier {
287    /// Ask for the proof of each of `txids`, once.
288    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// ---------------------------------------------------------------------------
327// The stored row
328// ---------------------------------------------------------------------------
329
330#[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    /// The courier of a `mempool_present` hint (tracker 0.5.0).
341    #[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/// One tracked transaction as the CLI's storage holds it: inputs to replay.
401#[derive(Debug, Clone, PartialEq, Eq)]
402struct TrackedRow {
403    txid: TxId,
404    built: bool,
405    /// Oldest first.
406    hints: Vec<StoredHint>,
407    /// The raw merkle path, hex. Checked again on every load.
408    bump: Option<String>,
409    asks: u32,
410    next_ask_at: Option<Timestamp>,
411    /// The BEEF to hand to the broadcasters again, and when.
412    reannounce_beef: Option<Vec<u8>>,
413    reannounce_at: Option<Timestamp>,
414    /// What the wallet records once a broadcaster takes it: the
415    /// `internalizeAction` arguments, JSON.
416    on_accept: Option<String>,
417    /// Chain events that left the checked word other than a plain
418    /// `mined` (stale, or suspect), oldest first, as their envelopes.
419    /// Replayed after the proof on every load.
420    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    /// The stored chain events, decoded. They were decoded once before
440    /// they were stored; one that no longer decodes is reported and not
441    /// replayed.
442    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    /// Keep `event` on the row when it left the state anything but a
456    /// plain checked `mined`; forget every kept event when it did. One
457    /// `tip` is kept at most (each rechecks the same suspect mark).
458    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    /// Feed the stored inputs to `tracker`, as [`TrackedRow::replay`] feeds
478    /// them to one state.
479    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    /// Replay the stored inputs. The stored path is checked against
509    /// `headers` like any other proof; a path that no longer checks leaves
510    /// the hint-tier word and a re-ask, and is returned as the fault.
511    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
533/// The tracker's word, as one lowercase label.
534pub 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    // Added with the chain-event feed (0.9.0); a table made before it
584    // gains the column empty.
585    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
686/// What the storage holds for `txid`: the word, the mined height and the
687/// pending re-ask the tracker gave at its last step. A view for a person;
688/// nothing is decided from it.
689pub 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// ---------------------------------------------------------------------------
708// What a broadcaster answered to a transaction the host just handed over
709// ---------------------------------------------------------------------------
710
711/// The broadcasters' answer to one post, read as a hint.
712#[derive(Debug, Clone, PartialEq, Eq)]
713pub enum PostAnswer {
714    /// A broadcaster took it.
715    Accepted { provider: String },
716    /// None took it and one called it not final: its lock time has not
717    /// passed for the node behind that broadcaster. Transient.
718    NotFinal { provider: String, said: String },
719    /// None took it, for some other reason (or none answered).
720    Refused { said: String },
721}
722
723/// Does a broadcaster's refusal say "not final"? The node's word is
724/// `non-final`; ARC and Arcade carry it under code 476, which the toolbox
725/// reads as retryable (bsv-wallet-toolbox-rs `ARCADE_CODE_NON_FINAL`).
726pub 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
736/// Read the results of one `post_beef`.
737pub 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/// A BEEF to hand to the broadcasters again, and when.
773#[derive(Debug, Clone)]
774pub struct Reannounce {
775    /// The BEEF, as it was posted.
776    pub beef: Vec<u8>,
777    /// Not before this time (seconds since the epoch).
778    pub at: Timestamp,
779    /// What the wallet records when a broadcaster takes it then.
780    pub on_accept: Option<InternalizeActionArgs>,
781}
782
783/// Track a transaction this wallet built and just handed to a broadcaster,
784/// and feed what the broadcaster said, as a hint. `not_before` holds every
785/// re-ask until that time (a transaction that cannot be mined before its
786/// lock time has no proof to ask for before it); `reannounce` keeps the
787/// BEEF to hand over again then. Returns the tracker's word and its
788/// pending re-ask.
789pub 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        // A row with a stored path is past this: its word is read, never
801        // rewritten from a hint.
802        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(&params, &headers);
812    if !row.hints.iter().any(|h| h.source == hint.source) {
813        row.hints.push(StoredHint::of(&hint));
814        let _ = state.step(&params, &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// ---------------------------------------------------------------------------
841// The pass
842// ---------------------------------------------------------------------------
843
844/// The host's parameters of a pass.
845#[derive(Debug, Clone)]
846pub struct TickOptions {
847    /// Seconds without a new hint before an announced or seen transaction
848    /// is asked for its proof (the tracker's `Params::age_threshold`).
849    pub age_threshold: Timestamp,
850    /// Re-asks one pass runs.
851    pub max_asks: usize,
852    /// How deep the block holding a coin's transaction must be before the
853    /// coin is offered (`SPEND_DEPTH`): the tip is depth 1. The stack's
854    /// wallet charter R3 (b), ruled 2026-10-12 (bsv-stack-lean
855    /// `lean/Wallet.lean`, `guardDepth`): the spend depth is the host's
856    /// parameter, named in its config, with 1 the floor; a number below 1
857    /// reads as 1. Read by [`spend_guard`], where the host decides whether
858    /// a coin may be spent.
859    pub spend_depth: Height,
860}
861
862impl TickOptions {
863    /// `TRACKER_AGE_SECS`, `TRACKER_MAX_ASKS` and `SPEND_DEPTH`, with their
864    /// defaults.
865    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    /// The options with the spend depth at its floor of 1 or above (R3).
881    #[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/// What one pass did.
895#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
896pub struct TickReport {
897    /// Rows read this pass (never the mined ones, unless a header moved).
898    pub tracked: usize,
899    /// The wallet's own unproven transactions tracked for the first time.
900    pub adopted: usize,
901    /// Proven creators of coins we hold, tracked for the first time from
902    /// the toolbox's stored proof replayed against the headers of the
903    /// moment: `mined` when its root checks.
904    pub adopted_mined: Vec<String>,
905    /// The same, whose stored root does not meet the header at its height:
906    /// tracked with the re-ask `proofFailed`, no word written.
907    pub adopted_proof_failed: Vec<String>,
908    /// New hints fed.
909    pub hints: usize,
910    /// Re-asks run: one proof request each.
911    pub asked: usize,
912    /// Transactions whose proof checked this pass: the word is `mined`.
913    pub mined: Vec<String>,
914    /// Re-asks that brought no proof; each is asked again after its pause.
915    pub no_proof: Vec<String>,
916    /// Mined rows whose stored path no longer meets the active header.
917    pub moved: Vec<String>,
918    /// The lowest height whose header moved since the last pass, if any.
919    pub moved_from: Option<Height>,
920    /// Transactions handed to the broadcasters again and accepted.
921    pub reannounced: Vec<String>,
922    /// Transactions handed over again and still called not final; each is
923    /// handed over again after a pause.
924    pub not_final: Vec<String>,
925    /// What the wallet is to record for each transaction accepted on a
926    /// re-announce (`txid`, the `internalizeAction` arguments): the caller
927    /// that holds the wallet runs [`record_accepted`].
928    #[serde(skip)]
929    pub on_accept: Vec<(String, String)>,
930    /// Faults the host reports: `txid: what failed`.
931    pub faults: Vec<String>,
932    /// What the wallet's storage did with each checked proof.
933    pub stored: Vec<String>,
934}
935
936impl TickReport {
937    /// Nothing moved and nothing was asked.
938    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    /// One line.
951    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
971/// The recent tips, newest last, read from and written to `tracker_meta`.
972async 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
993/// The lowest height whose header is no longer the one a past pass saw,
994/// and the ring with the moved entries dropped and the tip appended. One
995/// header read when the tip advanced, none when it did not, and one more
996/// per replaced block. Deeper than the ring, the ring's lowest height is
997/// the answer and the host says so.
998async 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                // Could not look: nothing is concluded this pass.
1017                _ => 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
1036/// One pass of the host. See the module docs.
1037pub 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    // The wallet's own transactions that are out and unproven, tracked
1049    // from the first pass that sees them. Never anyone else's.
1050    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    // Did a header we stood on move? Then the mined rows at or above it
1061    // are read again, and no other mined row is.
1062    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    // Read: the headers the stored paths name, and the new hints.
1082    snapshot
1083        .extend(services, rows.iter().filter_map(TrackedRow::proof_height))
1084        .await;
1085    let mut hints = MemoryHints::read(storage, &rows).await?;
1086
1087    // Step: replay each row, feed its hints, tick its clock.
1088    let mut states: HashMap<TxId, State> = HashMap::new();
1089    for row in rows.iter_mut() {
1090        let (state, fault) = row.replay(&params, &snapshot);
1091        if let Some(fault) = fault {
1092            // The stored path does not check against the active header:
1093            // the row is back at its hint-tier word with a re-ask, due now.
1094            if matches!(fault, CheckError::RootMismatch(_)) {
1095                // The path proves inclusion in a block that is no longer
1096                // active: it is dropped, and a new one is asked for.
1097                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(&params, &snapshot, Input::Hint(hint));
1122        report.hints += 1;
1123        // A word that disagrees is asked about now, not after the pause
1124        // an earlier re-ask earned.
1125        if matches!(state.reask(), Some(Reask::Hint(_))) {
1126            row.asks = 0;
1127            row.next_ask_at = None;
1128        }
1129    }
1130    // Announce again what a broadcaster called not final, at its time.
1131    // The lock time passing on our clock is not the node's rule passing
1132    // (the node reads the median time past, about an hour behind; the rule
1133    // is the sibling's, bsv-script-lean `docs/BLOCK-CONSENSUS.md` section
1134    // 2.2), so "not final" again is expected and waits one more pause.
1135    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(&params, &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                // Could not look: the same time again, one pause on.
1188                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(&params, &snapshot, Input::Host(HostAction::Tick(now)));
1197    }
1198
1199    // Read again: a proof for each re-ask that is due, and its header.
1200    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    // Step again: each proof through the tracker's check.
1214    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(&params, &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    // Keep the state, and hand each checked proof to the wallet's own
1264    // storage through its one proof funnel.
1265    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, &params, now, &mut report).await?;
1286    write_ring(storage, &ring).await?;
1287    Ok(report)
1288}
1289
1290/// Proven creators adopted per pass at most ([`adopt_proven`]).
1291pub const MAX_ADOPT_PER_PASS: usize = 100;
1292
1293/// Track the creators the toolbox proved before the tracker held a row for
1294/// them (its monitor's backstop, `check_for_proofs`, moves a transaction to
1295/// `completed` with `proven_tx_id` set): every `completed` transaction with
1296/// a stored proof that creates a coin we hold and has no tracker row. The
1297/// stored path is replayed as the row's input against the headers of the
1298/// moment, as the guard's [`read_creators`] replays it: a root that checks
1299/// gives the row the word `mined` at its height with that path as its
1300/// evidence; a root that does not meet the header at its height gives a row
1301/// with the pending re-ask `proofFailed`, which the next pass runs (the
1302/// path dropped, a fresh one asked for). A header the service could not
1303/// give, or a stored path that does not parse, adopts nothing this pass.
1304/// The toolbox's own rows are never written. The stack's wallet charter,
1305/// section 1: about a transaction the wallet believes only the tracker's
1306/// word, and each action hosts one tracker state.
1307///
1308/// Load: one store query per pass, and one header read per height not
1309/// already read this pass, for at most [`MAX_ADOPT_PER_PASS`] creators.
1310async 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            // Could not look: nothing is adopted, the next pass looks again.
1369            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
1389/// Record in the wallet what a pass's re-announces got accepted (E4): the
1390/// wallet's storage is the verdict for its own actions, so a claim the
1391/// wallet made is internalized from the BEEF it already holds, and nothing
1392/// scans the chain for it. Each outcome is added to `report.stored`.
1393pub 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
1412// ---------------------------------------------------------------------------
1413// The spend guard
1414// ---------------------------------------------------------------------------
1415
1416/// The wallet spend guard, as the tracker's README gives it (bsv-tracker
1417/// `README.md`, "Wallet: spend guard"), unchanged: the scheduled recheck
1418/// runs before the coin is used, and an unavailable header is an error that
1419/// prevents the spend.
1420fn 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
1430/// Run the spend guard for a coin of the tracked transaction `txid`, as
1431/// the host: the row is loaded (its stored path checked against a fresh
1432/// header), the README's guard runs on it, and the row is stored again.
1433/// `Ok(true)` only for a checked, non-suspect `mined`; `Err` when the
1434/// header service could not answer. A transaction the host does not track
1435/// is not mined.
1436pub 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(&params, &snapshot);
1450    match fault {
1451        // Could not look (no tip, no header at the height, a path that
1452        // does not parse): an error, no spend, and the row is left as it
1453        // was. "Could not look" changes no word.
1454        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, &params, &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/// The guard's reading of one coin's creator, for the coin gate
1466/// ([`crate::spend_gate`]).
1467#[derive(Debug, Clone, PartialEq, Eq)]
1468pub struct CreatorReading {
1469    /// The tracker's word after the guard's recheck, as its label.
1470    pub word: &'static str,
1471    /// How deep the creator's block is under the tip (the tip is 1); 0 for
1472    /// a word with no block.
1473    pub depth: u64,
1474    /// A fork in progress, or a split not yet resolved, at or below its
1475    /// height marks it suspect.
1476    pub suspect: bool,
1477    /// `mined`, checked against the headers of the moment, not suspect, at
1478    /// the spend depth (floor 1): the guard offers it with no rule.
1479    pub offered: bool,
1480    /// Could not look (no tip, no header at the height): the fault. Nothing
1481    /// is offered on it.
1482    pub fault: Option<String>,
1483}
1484
1485/// Run the spend guard over many creators against one read of the headers:
1486/// the tip once, and one header per height a stored path names. Each
1487/// creator is the tracked row when the tracker holds one, else the
1488/// toolbox's stored merkle path (`proven_txs`, given here as its bytes)
1489/// replayed as a row's input: a stored proof is checked again against the
1490/// headers of the moment, never trusted by age. A creator with neither
1491/// reads `unknown`. Nothing is written: the tracker's rows are not stored
1492/// back and no row is adopted.
1493pub 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(&params, &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, &params, &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
1559/// Is the block holding a `mined` word's transaction at least
1560/// `spend_depth` deep under `headers`' tip (the tip is depth 1; the stack's
1561/// `Tracker.depth hd h = hd.tip + 1 - h`)? The depth reads as 1 at least
1562/// (R3's floor). An unreadable tip is an error, as for the guard.
1563fn 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
1576// ---------------------------------------------------------------------------
1577// Chain events from the header service's feed
1578// ---------------------------------------------------------------------------
1579
1580/// The splits the feed has delivered and not yet seen resolved: each
1581/// split's key and its `forkHeight` (`tracker_meta`, key `open_splits`),
1582/// written in the delivery's transaction.
1583async 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
1596/// The lowest `forkHeight` of the splits still open, if any. The guard
1597/// holds every coin whose creator is mined at or above it until its
1598/// `splitResolved`: the header service answers one branch, and which
1599/// branch stands is open (depth is the host's; the ruling of 2026-10-16,
1600/// the scenario `tracker-split-observation-writes-no-word.md` column 4).
1601pub 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
1606/// Is a `mined` word at `height` above an open split?
1607fn under_split(word: &Word, floor: Option<Height>) -> bool {
1608    matches!((word, floor), (Word::Mined(m), Some(floor)) if floor <= m.height())
1609}
1610
1611/// The feed's stored cursor: the last envelope delivered, `0` at birth.
1612/// One row of `tracker_meta`, beside the tracker's rows.
1613pub 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
1627/// The frozen record: an outpoint the feed's `frozen` event named, with
1628/// the envelope's cursor and when it was delivered. The spend gate reads it
1629/// and never offers such a coin (R6: nothing here clears a row).
1630async 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
1644/// Every frozen outpoint: (lowercase txid, vout).
1645pub 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
1658/// How many outpoints the frozen record holds.
1659pub 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/// What one delivered envelope did to one tracked transaction.
1668#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1669pub struct ChainMove {
1670    /// The transaction the event named, by index.
1671    pub txid: String,
1672    /// The tracker's word after the event.
1673    pub word: String,
1674    /// Its pending re-ask, if any.
1675    pub reask: Option<String>,
1676    /// Marked by a fork or a split, awaiting a recheck.
1677    pub suspect: bool,
1678    /// The tracker's fault for this transaction, reported, if any.
1679    pub fault: Option<String>,
1680}
1681
1682/// One delivered envelope.
1683#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1684pub struct ChainDelivery {
1685    /// The envelope's cursor, now stored.
1686    pub cursor: u64,
1687    /// The event's kind.
1688    pub kind: &'static str,
1689    /// The transactions it named.
1690    pub moved: Vec<ChainMove>,
1691}
1692
1693/// The event's wire kind.
1694pub 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
1707/// The headers a reorg replaced, over the daemon's snapshot: a stored
1708/// proof is replayed against the header it was checked against (the
1709/// event's deactivated header at its height, the prior tip as the tip),
1710/// so the tracker sees the `mined` word the reorg takes away. The event
1711/// writes no `mined` word through this view; `Tracker::on_chain` turns
1712/// the words it names `stale`.
1713struct 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
1735/// Deliver one chain envelope (already decoded, at `cursor`) to the
1736/// tracked transactions it names, through `Tracker::on_chain` with the
1737/// daemon's headers, and store the moved rows and the cursor in one
1738/// transaction. A cursor at or below the stored one was delivered before
1739/// (delivery is at least once) and changes nothing.
1740///
1741/// The rows are named by index, never by a scan of the chain: a fork, a
1742/// reorg or a split names the mined rows at or above its height; a tip,
1743/// the rows an earlier event marked; an invalidated block, the rows on its
1744/// hash; a freeze or a tip age names none here (they are the spend and
1745/// freshness guards' inputs). A freeze writes its outpoint to the frozen
1746/// record ([`frozen_outpoints`]) in the same transaction as the cursor. The header service is read only when a row
1747/// is named: the tip and the heights of the named rows' proofs.
1748pub 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                // Asked now, not after the pause an earlier re-ask earned.
1861                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    /// A clock the test sets.
1925    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    /// A migrated in-memory wallet with one user.
1941    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    /// A coin of ours whose creator `txid` the toolbox's monitor proved
1972    /// before the tracker saw it: the transaction `completed` with
1973    /// `proven_tx_id` set to a stored proof at `height` (its root the
1974    /// txid), one unspent change output of `satoshis` in the default
1975    /// basket. A received coin (`is_outgoing` 0), as `fund` makes one.
1976    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    /// A header at `height` whose merkle root is `root`.
2035    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    /// A path proving `txid` alone in a block at `height`: its root is the
2049    /// txid.
2050    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    /// E3 (the rulings of 2026-10-09). The wallet's own unproven
2065    /// transaction is tracked, a broadcaster's acceptance announces it, and
2066    /// its age asks for a proof: one request, for that transaction. The
2067    /// proof checks against the header service's header and the word is
2068    /// `mined`. The row is in the wallet's database, and the next pass
2069    /// reads no mined row and asks nothing.
2070    #[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        // Young: announced on the hint, nothing asked.
2086        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        // Past the age threshold: the re-ask runs, the proof checks.
2097        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        // A mined row is not read again while no header moved.
2109        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    /// The evidence rule in the host: a broadcaster's "mined" is a hint. It
2117    /// asks for the proof at once; with no proof the word does not move,
2118    /// and the same transaction is not asked again until its pause is over.
2119    #[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        // One minute later: the pause is not over, nothing is asked.
2144        let report = tick(&storage, &services, &FixedClock(now + 60), &opts())
2145            .await
2146            .unwrap();
2147        assert_eq!(report.asked, 0);
2148        // After the pause it is asked again, and the pause doubles.
2149        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    /// A header we stood on moved: the mined rows at or above it are read
2161    /// again and asked again, and a mined row below it is not touched.
2162    #[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        // Height 200 is replaced by a block without `high`; it is mined
2191        // again at 201.
2192        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    /// E2 (the rulings of 2026-10-09). A transaction a broadcaster called
2216    /// not final is held with its BEEF: nothing is asked or announced
2217    /// before its lock time; at the lock time it is announced again; "not
2218    /// final" again (the node's clock is behind ours) waits one pause; an
2219    /// acceptance announces it, and from there its age asks for the proof.
2220    #[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        // Before the lock time: nothing announced, no proof asked.
2268        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        // At the lock time: announced again; the node still says not final.
2275        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        // One pause on: accepted. The word is the tracker's `announced`.
2287        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        // It is not announced a third time; its age asks for the proof.
2299        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    /// A courier's mempool presence (tracker 0.5.0) survives the stored row,
2308    /// its courier with it; a presence row without one reads as `unknown`.
2309    #[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    /// The words that mean "not final", and one that does not.
2331    #[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    /// A tip header carrying the hash its bytes have.
2352    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    /// R3 (b): the spend depth is the host's, named in its config, 1 the
2359    /// floor. A coin whose block is the tip (one block deep) is offered at
2360    /// depth 1 and held at depth 3; two blocks later it is offered at 3; a
2361    /// depth of 0 reads as 1.
2362    #[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    /// The README's wallet spend guard, run by the host over a stored row
2415    /// (bsv-tracker `README.md`, "Wallet: spend guard"). Three runs: the
2416    /// header is the one the proof was checked against; the header service
2417    /// cannot answer; the header moved.
2418    #[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        // 1. Mined, checked, not suspect: the coin may be spent.
2435        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        // 2. No tip from the header service: an error, and no spend.
2445        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        // 3. The header at the height moved: not mined, a re-ask pending.
2461        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        // A transaction the host does not track is not mined.
2473        let guard = spend_guard(&storage, &services, &clock, &opts(), &"f6".repeat(32))
2474            .await
2475            .unwrap();
2476        assert_eq!(guard, Ok(false));
2477
2478        // The guard's own error path, on a state held in memory: mined,
2479        // then the header service loses the height.
2480        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(&params, &held);
2486        assert_eq!(fault, None);
2487        let lost = HeaderSnapshot::take(&services, []).await;
2488        let guard = readme_spend_guard(&mut state, &params, &lost);
2489        println!("spend guard, height unanswered: {guard:?}");
2490        assert_eq!(guard, Err(CheckError::Unavailable(900)));
2491    }
2492
2493    /// F1 of the stack's coin read (bsv-stack-lean #164): the daemon's
2494    /// served pass tracks the wallet's creators. An `unproven` creator is
2495    /// adopted as before; a `completed` creator the toolbox's monitor proved
2496    /// first is adopted by replaying its stored path against the headers of
2497    /// the moment: `mined` with its evidence when the header at its height
2498    /// carries the root, the re-ask `proofFailed` and no word when it does
2499    /// not. A creator tracked while unproven keeps its row when the toolbox
2500    /// moves it to `completed`. The toolbox's rows are not written, and the
2501    /// next pass adopts nothing.
2502    #[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        // The toolbox's monitor proves `earlier` (completed), and two coins
2529        // arrive already proven: one whose root the header service's
2530        // header at 900 carries, one whose header at 950 carries another.
2531        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}