Skip to main content

bsv_wallet_cli/
backstop_log.rs

1//! Which provider served the merkle path behind each proof the toolbox's
2//! monitor stored by its backstop (`check_for_proofs`,
3//! `synchronize_transaction_statuses`).
4//!
5//! The toolbox (bsv-wallet-toolbox-rs 0.8.0) names the provider only at
6//! debug (`services.rs`, "get_merkle_path: proof served": the provider and
7//! the txid), and logs the stored proof at info without it
8//! (`monitor/tasks/check_for_proofs.rs`, marker `proof_backstop_hit`: the
9//! txid and the height). The providers it asks, in order, are Arcade (when
10//! configured), then WhatsOnChain, then Bitails: the break-glass couriers
11//! of rule 28 (bsv-stack-lean, the ruling of 2026-10-09; the toolbox's T6,
12//! T7). This layer reads the two lines and says them as one, at info:
13//!
14//! ```text
15//! INFO bsv_wallet_cli::backstop_log: the monitor's proof backstop stored a proof marker="proof_backstop_provider" provider=WhatsOnChain txid=… height=971555
16//! ```
17//!
18//! A proof the triage's status answer carried (Arcade's MINED document,
19//! marker `triage_inline_proof`) had no merkle path fetch; its provider
20//! reads `status-answer`. Nothing is fetched here and nothing decided: the
21//! proof was checked against the header service before it was stored.
22//!
23//! A layer cannot log through the dispatcher it is called from (the nested
24//! event is dropped), so the line goes over a channel to a task that logs
25//! it.
26
27use std::collections::HashMap;
28use std::fmt;
29use std::sync::Mutex;
30
31use tracing::field::{Field, Visit};
32use tracing::{Event, Level, Metadata, Subscriber};
33use tracing_subscriber::layer::{Context, Filter, Layer};
34
35/// The toolbox's module that names the provider.
36const SERVICES_TARGET: &str = "bsv_wallet_toolbox_rs::services::services";
37/// The toolbox's monitor task that stores a proof by its backstop.
38const BACKSTOP_TARGET: &str = "bsv_wallet_toolbox_rs::monitor::tasks::check_for_proofs";
39const SERVED: &str = "get_merkle_path: proof served";
40/// The txids whose provider is remembered at most (the tracker's own
41/// re-asks are served through the same call and never reach the backstop).
42const REMEMBERED: usize = 1024;
43
44/// One proof the backstop stored, and who served its path.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct BackstopProof {
47    /// The provider's name as the toolbox has it (`ArcadeV2`,
48    /// `WhatsOnChain`, `Bitails`), or `status-answer`.
49    pub provider: String,
50    pub txid: String,
51    /// The block height the toolbox stored, when it gave one.
52    pub height: Option<u32>,
53}
54
55/// The layer: it remembers the provider of each served path and hands one
56/// [`BackstopProof`] to `sink` per backstop hit.
57pub struct BackstopLayer {
58    served: Mutex<HashMap<String, String>>,
59    sink: Box<dyn Fn(BackstopProof) + Send + Sync>,
60}
61
62impl BackstopLayer {
63    pub fn new(sink: impl Fn(BackstopProof) + Send + Sync + 'static) -> Self {
64        Self {
65            served: Mutex::new(HashMap::new()),
66            sink: Box::new(sink),
67        }
68    }
69
70    /// The layer the binary installs: each hit is logged at info by a task
71    /// on the running runtime.
72    pub fn logging() -> Self {
73        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<BackstopProof>();
74        tokio::spawn(async move {
75            while let Some(hit) = rx.recv().await {
76                log(&hit);
77            }
78        });
79        Self::new(move |hit| {
80            let _ = tx.send(hit);
81        })
82    }
83}
84
85/// The line.
86pub fn log(hit: &BackstopProof) {
87    tracing::info!(
88        marker = "proof_backstop_provider",
89        provider = %hit.provider,
90        txid = %hit.txid,
91        height = ?hit.height,
92        "the monitor's proof backstop stored a proof"
93    );
94}
95
96/// Only the two toolbox lines reach the layer, whatever the log level.
97pub struct BackstopFilter;
98
99impl<S> Filter<S> for BackstopFilter {
100    fn enabled(&self, meta: &Metadata<'_>, _: &Context<'_, S>) -> bool {
101        meta.is_event()
102            && ((meta.target() == SERVICES_TARGET && *meta.level() == Level::DEBUG)
103                || (meta.target() == BACKSTOP_TARGET && *meta.level() == Level::INFO))
104    }
105}
106
107#[derive(Default)]
108struct Fields {
109    message: Option<String>,
110    marker: Option<String>,
111    provider: Option<String>,
112    txid: Option<String>,
113    block_height: Option<String>,
114}
115
116impl Visit for Fields {
117    fn record_str(&mut self, field: &Field, value: &str) {
118        self.set(field, value.to_string());
119    }
120
121    fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
122        self.set(field, format!("{value:?}"));
123    }
124}
125
126impl Fields {
127    fn set(&mut self, field: &Field, value: String) {
128        match field.name() {
129            "message" => self.message = Some(value),
130            "marker" => self.marker = Some(value),
131            "provider" => self.provider = Some(value),
132            "txid" => self.txid = Some(value),
133            "block_height" => self.block_height = Some(value),
134            _ => {}
135        }
136    }
137}
138
139/// `Some(971555)` (the toolbox logs the height as `?Option<u32>`), or a
140/// bare number.
141fn height_of(text: &str) -> Option<u32> {
142    let digits = text.trim_start_matches("Some(").trim_end_matches(')');
143    digits.parse().ok()
144}
145
146impl<S: Subscriber> Layer<S> for BackstopLayer {
147    fn on_event(&self, event: &Event<'_>, _: Context<'_, S>) {
148        let mut fields = Fields::default();
149        event.record(&mut fields);
150        let Some(txid) = fields.txid else {
151            return;
152        };
153        let mut served = self.served.lock().unwrap_or_else(|e| e.into_inner());
154        if fields.message.as_deref() == Some(SERVED) {
155            if let Some(provider) = fields.provider {
156                if served.len() >= REMEMBERED {
157                    served.clear();
158                }
159                served.insert(txid, provider);
160            }
161        } else if fields.marker.as_deref() == Some("proof_backstop_hit") {
162            let provider = served
163                .remove(&txid)
164                .unwrap_or_else(|| "status-answer".to_string());
165            drop(served);
166            (self.sink)(BackstopProof {
167                provider,
168                txid,
169                height: fields.block_height.as_deref().and_then(height_of),
170            });
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use std::sync::Arc;
179    use tracing_subscriber::layer::SubscriberExt;
180
181    /// The two toolbox lines, as the toolbox writes them, become one hit
182    /// naming the provider, the txid and the height; a hit with no served
183    /// path names the status answer.
184    #[test]
185    fn the_backstop_hit_names_the_provider_that_served_its_path() {
186        let hits: Arc<Mutex<Vec<BackstopProof>>> = Arc::default();
187        let sink = hits.clone();
188        let layer = BackstopLayer::new(move |hit| sink.lock().unwrap().push(hit));
189        let subscriber = tracing_subscriber::registry().with(layer.with_filter(BackstopFilter));
190        let txid = "9b".repeat(32);
191        let inline = "c4".repeat(32);
192        tracing::subscriber::with_default(subscriber, || {
193            tracing::debug!(
194                target: "bsv_wallet_toolbox_rs::services::services",
195                provider = %"WhatsOnChain",
196                txid = %txid,
197                "get_merkle_path: proof served"
198            );
199            tracing::info!(
200                target: "bsv_wallet_toolbox_rs::monitor::tasks::check_for_proofs",
201                txid = %txid,
202                status = ?"Completed",
203                block_height = ?Some(971_555u32),
204                marker = "proof_backstop_hit",
205                "Transaction status synchronized by backstop fetch"
206            );
207            tracing::info!(
208                target: "bsv_wallet_toolbox_rs::monitor::tasks::check_for_proofs",
209                txid = %inline,
210                block_height = ?Some(971_556u32),
211                marker = "proof_backstop_hit",
212                "Transaction status synchronized by backstop fetch"
213            );
214            // Another module's line with the same words is not read.
215            tracing::debug!(
216                target: "bsv_wallet_cli::elsewhere",
217                provider = %"Bitails",
218                txid = %inline,
219                "get_merkle_path: proof served"
220            );
221        });
222        let hits = hits.lock().unwrap();
223        println!("{hits:?}");
224        assert_eq!(
225            *hits,
226            vec![
227                BackstopProof {
228                    provider: "WhatsOnChain".into(),
229                    txid,
230                    height: Some(971_555),
231                },
232                BackstopProof {
233                    provider: "status-answer".into(),
234                    txid: inline,
235                    height: Some(971_556),
236                },
237            ]
238        );
239    }
240}