Skip to main content

solid_pod_rs_server/
mempool.rs

1//! Native mempool.space REST client — the read-side of block-trail anchors.
2//!
3//! [`MempoolHttpClient`](crate::mempool::MempoolHttpClient) is the server-side concrete implementation of the
4//! pure [`solid_pod_rs::mrc20::MempoolLookup`] trait. It speaks the
5//! mempool.space-style REST API over the `reqwest` client the crate already
6//! pulls in for the CORS proxy and webhook delivery:
7//!
8//! | Method | Path                              | Returns          |
9//! |--------|-----------------------------------|------------------|
10//! | GET    | `{base}/api/address/{addr}/utxo`  | `Vec<Utxo>`      |
11//! | GET    | `{base}/api/tx/{txid}`            | `TxInfo`         |
12//!
13//! The wire shapes (`status: {confirmed, block_height}` nested objects) are
14//! deserialised into local `*Wire` structs and flattened into the crate's
15//! transport-free [`Utxo`](solid_pod_rs::mrc20::Utxo)/[`TxInfo`](solid_pod_rs::mrc20::TxInfo) value types, so the pure verification
16//! surface never learns the mempool.space schema.
17//!
18//! ## wasm boundary
19//!
20//! This module is native-only (it builds a `reqwest::Client`). It mirrors
21//! the JSS `verifyMrc20Anchor` mempool round-trip (`mrc20.js:315-327`,
22//! `token.js:176-187`) and is the production [`MempoolLookup`](solid_pod_rs::mrc20::MempoolLookup) the
23//! `/pay/.deposit` MRC20 path and `/pay/.address` derivation use. wasm
24//! consumers implement [`MempoolLookup`](solid_pod_rs::mrc20::MempoolLookup) over `fetch` instead and never
25//! compile this file.
26//!
27//! ## Configuration
28//!
29//! The base URL is read from `JSS_PAY_MEMPOOL_URL` (JSS `mempoolUrl`
30//! parity), defaulting to the testnet4 explorer
31//! `https://mempool.space/testnet4`. The reqwest `json` feature is *not*
32//! enabled crate-wide, so responses are read as text and parsed with
33//! `serde_json` (matching the proxy handler's manual-parse style).
34//!
35//! The endpoint choice is not silent: [`select_mempool_endpoint`] resolves the
36//! base URL, [`infer_network`] classifies the Bitcoin network it serves, and
37//! [`log_mempool_selection`] records both (plus whether the URL was
38//! operator-supplied or defaulted) in the startup log — with a warning when the
39//! pod would otherwise be anchoring against an unchosen or unclassifiable
40//! chain. [`MempoolSelection::to_manifest_json`] renders the same facts for a
41//! manifest (ADR-2007).
42
43use async_trait::async_trait;
44use serde::Deserialize;
45
46use solid_pod_rs::bitcoin_tx::{anchor_state, MempoolBroadcast};
47use solid_pod_rs::mrc20::{bt_address, MempoolLookup, TxInfo, TxOut, Utxo};
48use solid_pod_rs::payments::PaymentError;
49use solid_pod_rs::provenance::{BlockAnchorer, BlockTrailAnchor, ProvenanceError};
50
51/// Environment variable selecting the mempool REST base URL (JSS parity).
52pub const MEMPOOL_URL_ENV: &str = "JSS_PAY_MEMPOOL_URL";
53
54/// Default base URL — the mempool.space **testnet4** explorer. Matches the
55/// JSS default (`pay.js:243`, `mrc20.js:282`).
56pub const DEFAULT_MEMPOOL_URL: &str = "https://mempool.space/testnet4";
57
58// ---------------------------------------------------------------------------
59// Endpoint selection (ADR-2007) — pure, no I/O
60// ---------------------------------------------------------------------------
61//
62// The base URL alone does not tell an operator *why* the pod is pointed where
63// it is, nor which Bitcoin network that endpoint serves. A silent fall back to
64// the built-in testnet4 default could otherwise have the pod anchoring and
65// verifying against a chain nobody chose. The types below make the choice
66// explicit, classifiable and loggable without performing any I/O, so the
67// startup path can record it in the log and in a manifest.
68
69/// Where the mempool base URL came from.
70///
71/// [`MempoolConfigSource::Explicit`] means an operator supplied it (via
72/// [`MEMPOOL_URL_ENV`] or [`MempoolHttpClient::new`]);
73/// [`MempoolConfigSource::Default`] means nothing was configured and the
74/// built-in [`DEFAULT_MEMPOOL_URL`] was used. The distinction matters because a
75/// defaulted endpoint is an *unchosen* chain.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum MempoolConfigSource {
78    /// The operator supplied the base URL.
79    Explicit,
80    /// Nothing was configured; [`DEFAULT_MEMPOOL_URL`] was used.
81    Default,
82}
83
84impl MempoolConfigSource {
85    /// Lower-case wire name (`"explicit"` / `"default"`) used in logs and the
86    /// manifest JSON.
87    #[must_use]
88    pub fn as_str(&self) -> &'static str {
89        match self {
90            Self::Explicit => "explicit",
91            Self::Default => "default",
92        }
93    }
94}
95
96impl std::fmt::Display for MempoolConfigSource {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.write_str(self.as_str())
99    }
100}
101
102/// The Bitcoin network a mempool endpoint is understood to serve.
103///
104/// [`BitcoinNetwork::Unknown`] is deliberate: an unrecognised operator-supplied
105/// explorer is *not* assumed to be mainnet (or anything else). Guessing here
106/// would reintroduce exactly the silent-wrong-chain risk this type exists to
107/// remove.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum BitcoinNetwork {
110    /// Bitcoin mainnet.
111    Mainnet,
112    /// The legacy testnet3 network (`/testnet` or `/testnet3`).
113    Testnet3,
114    /// The testnet4 network (the crate default).
115    Testnet4,
116    /// The signet test network.
117    Signet,
118    /// A local regtest node (loopback host with no network in the path).
119    Regtest,
120    /// Not classifiable from the URL — treat as unverified.
121    Unknown,
122}
123
124impl BitcoinNetwork {
125    /// Lower-case wire name used in logs and the manifest JSON.
126    #[must_use]
127    pub fn as_str(&self) -> &'static str {
128        match self {
129            Self::Mainnet => "mainnet",
130            Self::Testnet3 => "testnet3",
131            Self::Testnet4 => "testnet4",
132            Self::Signet => "signet",
133            Self::Regtest => "regtest",
134            Self::Unknown => "unknown",
135        }
136    }
137}
138
139impl std::fmt::Display for BitcoinNetwork {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.write_str(self.as_str())
142    }
143}
144
145/// Classify the Bitcoin network a mempool base URL serves, purely from the URL.
146///
147/// The trailing path segment is matched case-insensitively against the
148/// mempool.space layout:
149///
150/// | Base URL                         | Network    |
151/// |----------------------------------|------------|
152/// | `https://mempool.space`          | `Mainnet`  |
153/// | `https://mempool.space/testnet4` | `Testnet4` |
154/// | `https://mempool.space/testnet`  | `Testnet3` |
155/// | `https://mempool.space/testnet3` | `Testnet3` |
156/// | `https://mempool.space/signet`   | `Signet`   |
157/// | `http://127.0.0.1:3006`          | `Regtest`  |
158/// | anything else                    | `Unknown`  |
159///
160/// A loopback host is taken to be a local regtest node *unless* its path names a
161/// network explicitly (`http://localhost:3006/signet` is `Signet`). Anything
162/// unrecognised is [`BitcoinNetwork::Unknown`] — never a guess.
163#[must_use]
164pub fn infer_network(base_url: &str) -> BitcoinNetwork {
165    let trimmed = base_url.trim().trim_end_matches('/');
166
167    // Split scheme / authority / path by hand: the base may legitimately be
168    // scheme-relative in a fixture, and a full URL parser would reject it.
169    let after_scheme = match trimmed.find("://") {
170        Some(i) => &trimmed[i + 3..],
171        None => trimmed,
172    };
173    let (authority, path) = match after_scheme.find('/') {
174        Some(i) => (&after_scheme[..i], &after_scheme[i + 1..]),
175        None => (after_scheme, ""),
176    };
177
178    // Last non-empty path segment, ignoring any query/fragment tail.
179    let path = path
180        .split(['?', '#'])
181        .next()
182        .unwrap_or("")
183        .trim_end_matches('/');
184    let last_segment = path.rsplit('/').find(|seg| !seg.is_empty()).unwrap_or("");
185
186    match last_segment.to_ascii_lowercase().as_str() {
187        "testnet4" => return BitcoinNetwork::Testnet4,
188        "testnet" | "testnet3" => return BitcoinNetwork::Testnet3,
189        "signet" => return BitcoinNetwork::Signet,
190        "regtest" => return BitcoinNetwork::Regtest,
191        "mainnet" | "bitcoin" => return BitcoinNetwork::Mainnet,
192        _ => {}
193    }
194
195    // Host (minus any userinfo / port) drives the remaining cases.
196    let host = authority.rsplit('@').next().unwrap_or(authority);
197    let host = if let Some(rest) = host.strip_prefix('[') {
198        // Bracketed IPv6 literal: keep the address, drop the port.
199        rest.split(']').next().unwrap_or(rest)
200    } else {
201        host.split(':').next().unwrap_or(host)
202    };
203    let host_lower = host.to_ascii_lowercase();
204
205    let is_loopback = host_lower == "localhost"
206        || host_lower == "::1"
207        || host_lower.starts_with("127.")
208        || host_lower.ends_with(".localhost");
209    if is_loopback {
210        // A local node with no network in the path is a regtest node.
211        return BitcoinNetwork::Regtest;
212    }
213
214    // A bare mempool.space-style host with no network path is mainnet.
215    if path.is_empty() && (host_lower == "mempool.space" || host_lower.ends_with(".mempool.space"))
216    {
217        return BitcoinNetwork::Mainnet;
218    }
219
220    BitcoinNetwork::Unknown
221}
222
223/// The resolved mempool endpoint: which URL, which chain, and why.
224///
225/// Produced by [`select_mempool_endpoint`] /
226/// [`select_mempool_endpoint_from_env`] and carried on [`MempoolHttpClient`] so
227/// the choice can be logged at startup ([`log_mempool_selection`]) and surfaced
228/// in a manifest ([`MempoolSelection::to_manifest_json`]).
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct MempoolSelection {
231    /// The base URL actually in use, trailing slash trimmed.
232    pub base_url: String,
233    /// The Bitcoin network inferred from `base_url`.
234    pub network: BitcoinNetwork,
235    /// Whether `base_url` was operator-supplied or the built-in default.
236    pub source: MempoolConfigSource,
237}
238
239impl MempoolSelection {
240    /// Render the selection as manifest JSON:
241    /// `{"base_url": …, "network": …, "source": "explicit"|"default"}`.
242    #[must_use]
243    pub fn to_manifest_json(&self) -> serde_json::Value {
244        serde_json::json!({
245            "base_url": self.base_url,
246            "network": self.network.as_str(),
247            "source": self.source.as_str(),
248        })
249    }
250}
251
252/// Resolve the mempool endpoint from an optional configured value.
253///
254/// A `configured` value that is `None`, empty or whitespace-only is treated as
255/// absent (matching the historical `JSS_PAY_MEMPOOL_URL` filter), yielding
256/// [`DEFAULT_MEMPOOL_URL`] with [`MempoolConfigSource::Default`]. Any trailing
257/// `/` is trimmed so `{base}/api/...` joins cleanly. Pure — no environment or
258/// network access.
259#[must_use]
260pub fn select_mempool_endpoint(configured: Option<&str>) -> MempoolSelection {
261    let (raw, source) = match configured.map(str::trim).filter(|v| !v.is_empty()) {
262        Some(v) => (v, MempoolConfigSource::Explicit),
263        None => (DEFAULT_MEMPOOL_URL, MempoolConfigSource::Default),
264    };
265    let base_url = raw.trim_end_matches('/').to_string();
266    let network = infer_network(&base_url);
267    MempoolSelection {
268        base_url,
269        network,
270        source,
271    }
272}
273
274/// Resolve the mempool endpoint from [`MEMPOOL_URL_ENV`], delegating the (pure)
275/// decision to [`select_mempool_endpoint`].
276#[must_use]
277pub fn select_mempool_endpoint_from_env() -> MempoolSelection {
278    let configured = std::env::var(MEMPOOL_URL_ENV).ok();
279    select_mempool_endpoint(configured.as_deref())
280}
281
282/// Record the selected endpoint in the startup log.
283///
284/// Emits exactly one `INFO` on target `solid_pod_rs_server::mempool` carrying
285/// the structured fields `base_url`, `network` and `source`, so an operator can
286/// tell from the log which chain the pod anchors and verifies against. When the
287/// network could not be classified, or the endpoint was defaulted rather than
288/// chosen, an additional `WARN` names [`MEMPOOL_URL_ENV`] as the fix.
289pub fn log_mempool_selection(sel: &MempoolSelection) {
290    tracing::info!(
291        target: "solid_pod_rs_server::mempool",
292        base_url = %sel.base_url,
293        network = sel.network.as_str(),
294        source = sel.source.as_str(),
295        "mempool endpoint selected"
296    );
297    if sel.network == BitcoinNetwork::Unknown || sel.source == MempoolConfigSource::Default {
298        tracing::warn!(
299            target: "solid_pod_rs_server::mempool",
300            base_url = %sel.base_url,
301            network = sel.network.as_str(),
302            source = sel.source.as_str(),
303            env_var = MEMPOOL_URL_ENV,
304            "pod is using an unverified or defaulted Bitcoin endpoint; anchors may be \
305             written to or verified against an unintended chain — set {} to pin the \
306             explorer, and therefore the network, explicitly",
307            MEMPOOL_URL_ENV
308        );
309    }
310}
311
312/// Emit [`log_mempool_selection`] at most once for the lifetime of the
313/// process.
314///
315/// `MempoolHttpClient::from_env` is called per request on the payment and
316/// provenance routes, so logging unconditionally there would repeat the
317/// startup record on every request. The selection is process-global (it comes
318/// from an environment variable), so one record is exactly the right number:
319/// it lands in the startup log the first time the endpoint is resolved, and
320/// `solid-pod-rs-server`'s `main` calls this explicitly at boot so the record
321/// appears even on a pod that never serves a payment route.
322///
323/// Callers that want the value rather than the log should read
324/// [`MempoolHttpClient::selection`] or call [`select_mempool_endpoint`].
325pub fn log_mempool_selection_once(sel: &MempoolSelection) {
326    static ONCE: std::sync::Once = std::sync::Once::new();
327    ONCE.call_once(|| log_mempool_selection(sel));
328}
329
330/// A [`MempoolLookup`] backed by the mempool.space REST API over `reqwest`.
331///
332/// Cheap to clone (holds an `Arc`-internal `reqwest::Client` and the base
333/// URL). Construct with [`MempoolHttpClient::from_env`] to honour
334/// `JSS_PAY_MEMPOOL_URL`, or [`MempoolHttpClient::new`] for an explicit base.
335#[derive(Debug, Clone)]
336pub struct MempoolHttpClient {
337    client: reqwest::Client,
338    /// Base URL with any trailing slash trimmed (so `{base}/api/...` joins
339    /// cleanly regardless of how the operator wrote the env value).
340    base: String,
341    /// The resolved endpoint choice — URL, inferred network, and where the URL
342    /// came from — so the selection is recordable rather than an invisible
343    /// default (ADR-2007).
344    selection: MempoolSelection,
345}
346
347impl MempoolHttpClient {
348    /// Construct a client against an explicit base URL (e.g.
349    /// `https://mempool.space/testnet4`). A trailing `/` is trimmed.
350    #[must_use]
351    pub fn new(base_url: impl Into<String>) -> Self {
352        let raw = base_url.into();
353        let base = raw.trim_end_matches('/').to_string();
354        // An explicit constructor argument is operator-chosen by definition,
355        // even when it happens to equal DEFAULT_MEMPOOL_URL.
356        let selection = MempoolSelection {
357            network: infer_network(&base),
358            base_url: base,
359            source: MempoolConfigSource::Explicit,
360        };
361        Self::from_selection(selection)
362    }
363
364    /// Construct from an already-resolved [`MempoolSelection`]. Does not log —
365    /// the caller owns whether this choice is recorded (see
366    /// [`log_mempool_selection`]).
367    #[must_use]
368    pub fn from_selection(selection: MempoolSelection) -> Self {
369        Self {
370            client: reqwest::Client::new(),
371            base: selection.base_url.clone(),
372            selection,
373        }
374    }
375
376    /// Construct from `JSS_PAY_MEMPOOL_URL`, falling back to
377    /// [`DEFAULT_MEMPOOL_URL`] (testnet4).
378    ///
379    /// The resolved endpoint is logged exactly once via
380    /// [`log_mempool_selection`], so the startup log always records which
381    /// explorer — and therefore which Bitcoin network — the pod is using, with
382    /// a warning when that endpoint was defaulted or is unclassifiable.
383    #[must_use]
384    pub fn from_env() -> Self {
385        let selection = select_mempool_endpoint_from_env();
386        log_mempool_selection_once(&selection);
387        Self::from_selection(selection)
388    }
389
390    /// The configured base URL (trailing slash trimmed).
391    #[must_use]
392    pub fn base_url(&self) -> &str {
393        &self.base
394    }
395
396    /// The resolved endpoint selection: base URL, inferred Bitcoin network, and
397    /// whether the URL was operator-supplied or defaulted.
398    #[must_use]
399    pub fn selection(&self) -> &MempoolSelection {
400        &self.selection
401    }
402
403    /// Check whether a transaction is known without collapsing an HTTP 404
404    /// into the same result as an ambiguous transport/server failure. Payment
405    /// intent recovery may compensate a debit only for `Ok(false)`.
406    pub async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError> {
407        let url = format!("{}/api/tx/{txid}", self.base);
408        let resp = self
409            .client
410            .get(&url)
411            .send()
412            .await
413            .map_err(|e| PaymentError::InvalidState(format!("mempool request failed: {e}")))?;
414        if resp.status() == reqwest::StatusCode::NOT_FOUND {
415            return Ok(false);
416        }
417        if !resp.status().is_success() {
418            return Err(PaymentError::InvalidState(format!(
419                "mempool API error: {} for {url}",
420                resp.status().as_u16()
421            )));
422        }
423        Ok(true)
424    }
425
426    /// GET `url`, returning the body text on a 2xx, or a fail-closed
427    /// [`PaymentError::InvalidState`] describing the transport/status error.
428    async fn get_text(&self, url: &str) -> Result<String, PaymentError> {
429        let resp = self
430            .client
431            .get(url)
432            .send()
433            .await
434            .map_err(|e| PaymentError::InvalidState(format!("mempool request failed: {e}")))?;
435        let status = resp.status();
436        if !status.is_success() {
437            return Err(PaymentError::InvalidState(format!(
438                "mempool API error: {} for {url}",
439                status.as_u16()
440            )));
441        }
442        resp.text()
443            .await
444            .map_err(|e| PaymentError::InvalidState(format!("mempool body read failed: {e}")))
445    }
446
447    /// POST `body` as `text/plain` to `url`, returning the response body on a
448    /// 2xx (the txid, for `/api/tx`) or a fail-closed
449    /// [`PaymentError::InvalidState`]. Mirrors JSS `broadcastTx`
450    /// (`token.js:176-187`).
451    async fn post_text(&self, url: &str, body: &str) -> Result<String, PaymentError> {
452        let resp = self
453            .client
454            .post(url)
455            .header("Content-Type", "text/plain")
456            .body(body.to_string())
457            .send()
458            .await
459            .map_err(|e| PaymentError::InvalidState(format!("mempool broadcast failed: {e}")))?;
460        let status = resp.status();
461        let text = resp
462            .text()
463            .await
464            .map_err(|e| PaymentError::InvalidState(format!("mempool body read failed: {e}")))?;
465        if !status.is_success() {
466            return Err(PaymentError::InvalidState(format!(
467                "broadcast rejected ({}): {text}",
468                status.as_u16()
469            )));
470        }
471        Ok(text.trim().to_string())
472    }
473}
474
475// ── Wire shapes (mempool.space schema) ──────────────────────────────────
476
477/// Nested `status` object on UTXO/tx responses.
478#[derive(Debug, Deserialize, Default)]
479struct StatusWire {
480    #[serde(default)]
481    confirmed: bool,
482    #[serde(default)]
483    block_height: Option<u64>,
484}
485
486/// One element of `GET /api/address/{addr}/utxo`.
487#[derive(Debug, Deserialize)]
488struct UtxoWire {
489    txid: String,
490    vout: u32,
491    #[serde(default)]
492    value: u64,
493    #[serde(default)]
494    status: StatusWire,
495}
496
497impl From<UtxoWire> for Utxo {
498    fn from(w: UtxoWire) -> Self {
499        Utxo {
500            txid: w.txid,
501            vout: w.vout,
502            value: w.value,
503            confirmed: w.status.confirmed,
504            block_height: w.status.block_height,
505        }
506    }
507}
508
509/// One element of a tx's `vout` array.
510#[derive(Debug, Deserialize, Default)]
511struct TxOutWire {
512    #[serde(default)]
513    value: u64,
514    #[serde(default)]
515    scriptpubkey: Option<String>,
516    #[serde(default)]
517    scriptpubkey_address: Option<String>,
518}
519
520impl From<TxOutWire> for TxOut {
521    fn from(w: TxOutWire) -> Self {
522        TxOut {
523            value: w.value,
524            scriptpubkey: w.scriptpubkey,
525            scriptpubkey_address: w.scriptpubkey_address,
526        }
527    }
528}
529
530/// Shape of `GET /api/tx/{txid}`.
531#[derive(Debug, Deserialize)]
532struct TxWire {
533    txid: String,
534    #[serde(default)]
535    vout: Vec<TxOutWire>,
536    #[serde(default)]
537    status: StatusWire,
538}
539
540impl From<TxWire> for TxInfo {
541    fn from(w: TxWire) -> Self {
542        TxInfo {
543            txid: w.txid,
544            vout: w.vout.into_iter().map(TxOut::from).collect(),
545            confirmed: w.status.confirmed,
546            block_height: w.status.block_height,
547        }
548    }
549}
550
551#[async_trait(?Send)]
552impl MempoolLookup for MempoolHttpClient {
553    async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
554        let url = format!("{}/api/address/{address}/utxo", self.base);
555        let body = self.get_text(&url).await?;
556        let wire: Vec<UtxoWire> = serde_json::from_str(&body)
557            .map_err(|e| PaymentError::InvalidState(format!("malformed utxo JSON: {e}")))?;
558        Ok(wire.into_iter().map(Utxo::from).collect())
559    }
560
561    async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
562        let url = format!("{}/api/tx/{txid}", self.base);
563        let body = self.get_text(&url).await?;
564        let wire: TxWire = serde_json::from_str(&body)
565            .map_err(|e| PaymentError::InvalidState(format!("malformed tx JSON: {e}")))?;
566        Ok(TxInfo::from(wire))
567    }
568}
569
570#[async_trait(?Send)]
571impl MempoolBroadcast for MempoolHttpClient {
572    async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
573        let url = format!("{}/api/tx", self.base);
574        self.post_text(&url, raw_hex).await
575    }
576}
577
578// ---------------------------------------------------------------------------
579// BlockAnchorer::verify — the portable-proof read-side (provenance §2.2)
580// ---------------------------------------------------------------------------
581
582/// A [`BlockAnchorer`] implementing **both** sides over a transport that can
583/// look up UTXOs ([`MempoolLookup`]) and broadcast transactions
584/// ([`MempoolBroadcast`]). Generic over that transport so a fixture drives it
585/// in tests and [`MempoolHttpClient`] drives it in production — without
586/// changing the logic.
587///
588/// - `verify` (Phase 3) re-derives the expected taproot address from the
589///   anchor's *portable proof* (`pubkey` + `state_strings`) via [`bt_address`],
590///   rejects a forged `address`, and confirms a UTXO sits at the derived
591///   address. No pod trust required.
592/// - `anchor` (Phase 4) loads the named trail from storage, appends an MRC20
593///   state notarising `state_hash` (via
594///   [`anchor_state`], broadcasts the
595///   anchoring tx, persists the updated trail, and returns the
596///   [`BlockTrailAnchor`] (txid/vout/address/state_strings/pubkey). It requires
597///   a `storage` handle (set via [`MempoolBlockAnchorer::with_storage`]); the
598///   verify-only constructor [`MempoolBlockAnchorer::new`] leaves it `None` and
599///   `anchor()` then errors with a clear message.
600#[derive(Clone)]
601pub struct MempoolBlockAnchorer<M: MempoolLookup + MempoolBroadcast + Send + Sync> {
602    lookup: M,
603    storage: Option<std::sync::Arc<dyn solid_pod_rs::storage::Storage>>,
604}
605
606impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> MempoolBlockAnchorer<M> {
607    /// Wrap a transport as a **verify-capable** [`BlockAnchorer`]. `anchor()`
608    /// is unavailable (no storage) and returns an error explaining that
609    /// [`with_storage`](Self::with_storage) is required.
610    pub fn new(lookup: M) -> Self {
611        Self {
612            lookup,
613            storage: None,
614        }
615    }
616
617    /// Wrap a transport + pod storage as a **fully-capable** [`BlockAnchorer`]
618    /// (both `verify` and `anchor`). The `storage` backs the trail load/save at
619    /// `/.well-known/token/{ticker}.json`.
620    pub fn with_storage(
621        lookup: M,
622        storage: std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
623    ) -> Self {
624        Self {
625            lookup,
626            storage: Some(storage),
627        }
628    }
629
630    /// Borrow the underlying transport (e.g. for a one-off `address_utxos`).
631    pub fn lookup(&self) -> &M {
632        &self.lookup
633    }
634}
635
636#[async_trait(?Send)]
637impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> BlockAnchorer for MempoolBlockAnchorer<M> {
638    /// Append one MRC20 state anchoring `state_hash` under `ticker`, build +
639    /// broadcast the anchoring tx, persist the updated trail, and return the
640    /// produced [`BlockTrailAnchor`]. This is the expensive-tier write the
641    /// provenance design hinges on (ADR-059 §2.2, master-plan Phase 4).
642    ///
643    /// `network` is honoured as a guard: it must match the trail's own network
644    /// (the trail's chained-key addresses are network-bound). The returned
645    /// anchor's `vout` is `0` (the anchoring tx pays the next chained-key UTXO
646    /// at output 0); `blockheight` is `None` until the tx confirms.
647    async fn anchor(
648        &self,
649        ticker: &str,
650        state_hash: &str,
651        network: &str,
652    ) -> Result<BlockTrailAnchor, ProvenanceError> {
653        use crate::trail_store::{load_trail, save_trail};
654        use solid_pod_rs::bitcoin_tx::DEFAULT_FEE_SATS;
655
656        let storage = self.storage.as_ref().ok_or_else(|| {
657            ProvenanceError::Anchor(
658                "anchor() requires storage; construct with MempoolBlockAnchorer::with_storage"
659                    .into(),
660            )
661        })?;
662
663        // Load the trail that will carry the anchor (JSS `loadTrail`).
664        let mut stored = load_trail(storage, ticker)
665            .await
666            .map_err(|e| ProvenanceError::Anchor(format!("load trail {ticker}: {e}")))?
667            .ok_or_else(|| {
668                ProvenanceError::Anchor(format!("trail {ticker} not minted on this pod"))
669            })?;
670
671        if stored.network != network {
672            return Err(ProvenanceError::Anchor(format!(
673                "network mismatch: trail is {}, requested {network}",
674                stored.network
675            )));
676        }
677
678        // Build the anchoring tx (appends a state notarising `state_hash`).
679        let public = stored.to_public();
680        let update = anchor_state(
681            &public,
682            &stored.privkey,
683            state_hash,
684            DEFAULT_FEE_SATS,
685            &self.lookup,
686        )
687        .await
688        .map_err(|e| ProvenanceError::Anchor(format!("build anchoring tx: {e}")))?;
689
690        // Broadcast (JSS `broadcastTx`). The returned txid IS the anchoring tx.
691        let txid = self
692            .lookup
693            .broadcast_tx(&update.tx.raw_hex)
694            .await
695            .map_err(|e| ProvenanceError::Anchor(format!("broadcast anchoring tx: {e}")))?;
696
697        // Persist the appended trail with the broadcast txid as the new
698        // currentTxid (so the next anchor/transfer spends this output).
699        let mut appended = update.trail.clone();
700        appended.current_txid = txid.clone();
701        stored.merge_public(&appended);
702        stored.current_txid = txid.clone();
703        stored.current_vout = 0;
704        save_trail(storage, &stored)
705            .await
706            .map_err(|e| ProvenanceError::Anchor(format!("save trail: {e}")))?;
707
708        Ok(BlockTrailAnchor {
709            ticker: ticker.to_string(),
710            state_hash: state_hash.to_string(),
711            txid,
712            vout: 0,
713            address: update.address,
714            network: network.to_string(),
715            blockheight: None,
716            state_strings: appended.state_strings,
717            pubkey: Some(stored.pubkey_base),
718        })
719    }
720
721    async fn verify(&self, anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
722        // The portable proof requires both the issuer pubkey and the state
723        // strings. Absent either, there is nothing to independently
724        // re-derive against → not verifiable (false, not error).
725        let Some(pubkey) = anchor.pubkey.as_deref() else {
726            return Ok(false);
727        };
728        if anchor.state_strings.is_empty() {
729            return Ok(false);
730        }
731
732        // Re-derive the taproot address from the proof and reject a forged
733        // `address` field (the recorded address must equal the derivation).
734        let derived = bt_address(pubkey, &anchor.state_strings, &anchor.network)
735            .map_err(|e| ProvenanceError::Anchor(format!("address re-derivation failed: {e}")))?;
736        if derived != anchor.address {
737            return Ok(false);
738        }
739
740        // A genuine anchor has a live UTXO at the derived address.
741        let utxos = self
742            .lookup
743            .address_utxos(&derived)
744            .await
745            .map_err(|e| ProvenanceError::Anchor(format!("mempool lookup failed: {e}")))?;
746        Ok(!utxos.is_empty())
747    }
748}
749
750// ---------------------------------------------------------------------------
751// Tests — fixture parsing only (NO live mempool.space access).
752// ---------------------------------------------------------------------------
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757
758    /// A captured mempool.space `GET /api/address/{addr}/utxo` payload:
759    /// one confirmed UTXO. Deserialises into the flat [`Utxo`].
760    const UTXO_JSON: &str = include_str!("../tests/fixtures/mempool/address_utxos.json");
761    /// A captured `GET /api/tx/{txid}` payload (one confirmed tx, 2 outputs).
762    const TX_JSON: &str = include_str!("../tests/fixtures/mempool/tx.json");
763    /// An empty UTXO set (`[]`) — the "no deposit yet" response.
764    const EMPTY_UTXO_JSON: &str = "[]";
765
766    #[test]
767    fn utxo_wire_flattens_status() {
768        let wire: Vec<UtxoWire> = serde_json::from_str(UTXO_JSON).unwrap();
769        let utxos: Vec<Utxo> = wire.into_iter().map(Utxo::from).collect();
770        assert_eq!(utxos.len(), 1);
771        assert_eq!(utxos[0].vout, 0);
772        assert_eq!(utxos[0].value, 9700);
773        assert!(
774            utxos[0].confirmed,
775            "status.confirmed must flatten onto Utxo"
776        );
777        assert_eq!(utxos[0].block_height, Some(42_000));
778    }
779
780    #[test]
781    fn empty_utxo_set_parses_to_empty_vec() {
782        let wire: Vec<UtxoWire> = serde_json::from_str(EMPTY_UTXO_JSON).unwrap();
783        assert!(wire.is_empty());
784    }
785
786    #[test]
787    fn tx_wire_flattens_outputs_and_status() {
788        let wire: TxWire = serde_json::from_str(TX_JSON).unwrap();
789        let tx = TxInfo::from(wire);
790        assert_eq!(tx.vout.len(), 2);
791        assert_eq!(tx.vout[0].value, 9700);
792        assert_eq!(
793            tx.vout[0].scriptpubkey.as_deref(),
794            Some("5120aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899")
795        );
796        assert_eq!(
797            tx.vout[0].scriptpubkey_address.as_deref(),
798            Some("tb1pexampleaddress")
799        );
800        assert!(tx.confirmed);
801        assert_eq!(tx.block_height, Some(42_000));
802    }
803
804    #[test]
805    fn from_env_defaults_to_testnet4() {
806        // Snapshot/restore so a parallel test or the host env can't perturb it.
807        let prev = std::env::var(MEMPOOL_URL_ENV).ok();
808        std::env::remove_var(MEMPOOL_URL_ENV);
809        let c = MempoolHttpClient::from_env();
810        assert_eq!(c.base_url(), DEFAULT_MEMPOOL_URL);
811        if let Some(v) = prev {
812            std::env::set_var(MEMPOOL_URL_ENV, v);
813        }
814    }
815
816    #[test]
817    fn new_trims_trailing_slash() {
818        let c = MempoolHttpClient::new("https://mempool.space/testnet4/");
819        assert_eq!(c.base_url(), "https://mempool.space/testnet4");
820    }
821
822    /// Live smoke test — disabled by default (no live chain in CI). Run with
823    /// `cargo test -p solid-pod-rs-server --features git -- --ignored live_`.
824    #[ignore = "hits live mempool.space; opt-in only"]
825    #[tokio::test]
826    async fn live_address_utxos_smoke() {
827        let c = MempoolHttpClient::from_env();
828        // A well-known testnet4 faucet-ish address may have UTXOs; the test
829        // only asserts the call shape succeeds (empty is acceptable).
830        let _ = c
831            .address_utxos("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c")
832            .await;
833    }
834
835    // ── BlockAnchorer::verify over a FIXTURE MempoolLookup (no network) ──
836
837    use std::collections::HashMap;
838
839    /// In-memory [`MempoolLookup`] + [`MempoolBroadcast`] — address→UTXO and
840    /// txid→outputs maps. No HTTP. Interior mutability so the broadcast side
841    /// can record raw txs and the anchor round-trip can register the spent
842    /// output's scriptPubKey.
843    #[derive(Clone, Default)]
844    struct FixtureMempool {
845        utxos: std::sync::Arc<std::sync::Mutex<HashMap<String, Vec<Utxo>>>>,
846        txs: std::sync::Arc<std::sync::Mutex<HashMap<String, Vec<TxOut>>>>,
847        broadcasts: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
848    }
849    impl FixtureMempool {
850        fn with_utxo_at(address: &str) -> Self {
851            let me = Self::default();
852            me.utxos.lock().unwrap().insert(
853                address.to_string(),
854                vec![Utxo {
855                    txid: "ab".repeat(32),
856                    vout: 0,
857                    value: 9700,
858                    confirmed: true,
859                    block_height: Some(42_000),
860                }],
861            );
862            me
863        }
864        fn empty() -> Self {
865            Self::default()
866        }
867        fn add_output(&self, txid: &str, vout: u32, spk_hex: &str) {
868            let mut txs = self.txs.lock().unwrap();
869            let outs = txs.entry(txid.to_string()).or_default();
870            while outs.len() <= vout as usize {
871                outs.push(TxOut {
872                    value: 0,
873                    scriptpubkey: None,
874                    scriptpubkey_address: None,
875                });
876            }
877            outs[vout as usize] = TxOut {
878                value: 0,
879                scriptpubkey: Some(spk_hex.to_string()),
880                scriptpubkey_address: None,
881            };
882        }
883    }
884    #[async_trait(?Send)]
885    impl MempoolLookup for FixtureMempool {
886        async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
887            Ok(self
888                .utxos
889                .lock()
890                .unwrap()
891                .get(address)
892                .cloned()
893                .unwrap_or_default())
894        }
895        async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
896            Ok(TxInfo {
897                txid: txid.to_string(),
898                vout: self
899                    .txs
900                    .lock()
901                    .unwrap()
902                    .get(txid)
903                    .cloned()
904                    .unwrap_or_default(),
905                confirmed: true,
906                block_height: Some(42_000),
907            })
908        }
909    }
910    #[async_trait(?Send)]
911    impl MempoolBroadcast for FixtureMempool {
912        async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
913            // Synthetic, stable txid (sha256 of raw hex) — crypto correctness
914            // is asserted elsewhere; the chain-walk only needs uniqueness.
915            let txid = solid_pod_rs::mrc20::sha256_hex(raw_hex);
916            self.broadcasts.lock().unwrap().push(raw_hex.to_string());
917            Ok(txid)
918        }
919    }
920
921    // Issuer keypair (arbitrary) for deriving real anchor addresses.
922    const ISSUER_PRIVKEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
923    fn issuer_pubkey() -> String {
924        let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
925        hex::encode(sk.public_key().to_sec1_bytes())
926    }
927
928    /// Build a `BlockTrailAnchor` whose `address`/`state_strings`/`pubkey`
929    /// are internally consistent (the `address` is the genuine derivation).
930    fn consistent_anchor() -> BlockTrailAnchor {
931        let pubkey = issuer_pubkey();
932        let state_strings = vec!["{\"seq\":0}".to_string(), "{\"seq\":1}".to_string()];
933        let address = bt_address(&pubkey, &state_strings, "testnet4").unwrap();
934        BlockTrailAnchor {
935            ticker: "PROV".into(),
936            state_hash: "ff".repeat(32),
937            txid: "ab".repeat(32),
938            vout: 0,
939            address,
940            network: "testnet4".into(),
941            blockheight: Some(42_000),
942            state_strings,
943            pubkey: Some(pubkey),
944        }
945    }
946
947    #[tokio::test]
948    async fn block_anchorer_verify_true_when_utxo_present() {
949        let anchor = consistent_anchor();
950        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&anchor.address));
951        assert!(
952            anchorer.verify(&anchor).await.unwrap(),
953            "present UTXO ⇒ verify true"
954        );
955    }
956
957    #[tokio::test]
958    async fn block_anchorer_verify_false_when_utxo_absent() {
959        let anchor = consistent_anchor();
960        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
961        assert!(
962            !anchorer.verify(&anchor).await.unwrap(),
963            "absent UTXO ⇒ verify false"
964        );
965    }
966
967    #[tokio::test]
968    async fn block_anchorer_verify_false_when_address_forged() {
969        // A UTXO sits at the real derived address, but the anchor *claims* a
970        // different (forged) address → the re-derivation mismatch fails it.
971        let mut anchor = consistent_anchor();
972        let real = anchor.address.clone();
973        anchor.address = "tb1pforged000000000000000000000000000000".into();
974        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&real));
975        assert!(
976            !anchorer.verify(&anchor).await.unwrap(),
977            "forged address must not verify even with a real UTXO elsewhere"
978        );
979    }
980
981    #[tokio::test]
982    async fn block_anchorer_verify_false_without_pubkey() {
983        // No pubkey ⇒ nothing to re-derive against ⇒ not verifiable.
984        let mut anchor = consistent_anchor();
985        anchor.pubkey = None;
986        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&anchor.address));
987        assert!(!anchorer.verify(&anchor).await.unwrap());
988    }
989
990    #[tokio::test]
991    async fn block_anchorer_anchor_requires_storage() {
992        // The verify-only constructor leaves storage None ⇒ anchor() errors
993        // with a clear message rather than panicking.
994        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
995        let err = anchorer
996            .anchor("PROV", "deadbeef", "testnet4")
997            .await
998            .unwrap_err();
999        match err {
1000            ProvenanceError::Anchor(m) => assert!(m.contains("with_storage")),
1001            other => panic!("expected Anchor(requires storage), got {other:?}"),
1002        }
1003    }
1004
1005    // ── Phase 4: full anchor() round-trip (mint → store → anchor → verify) ──
1006
1007    use crate::trail_store::{load_trail, save_trail, StoredTrail};
1008    use solid_pod_rs::bitcoin_tx::mint_token;
1009    use solid_pod_rs::storage::memory::MemoryBackend;
1010    use solid_pod_rs::storage::Storage;
1011
1012    /// Mint a genesis trail through the write-side, persist it (with the
1013    /// issuer secret), and register the genesis UTXO's scriptPubKey so a
1014    /// subsequent anchor can spend it. Returns `(storage, mempool, ticker)`.
1015    async fn mint_and_store(ticker: &str) -> (std::sync::Arc<dyn Storage>, FixtureMempool, String) {
1016        let mempool = FixtureMempool::empty();
1017        let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
1018
1019        // Fund the genesis from an issuer-key voucher (untweaked path).
1020        let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
1021        let compressed = sk.public_key().to_sec1_bytes();
1022        let xonly_hex = hex::encode(&compressed[1..]);
1023        let voucher_txid = "11".repeat(32);
1024        mempool.add_output(&voucher_txid, 0, &format!("5120{xonly_hex}"));
1025
1026        let voucher = solid_pod_rs::bitcoin_tx::TxoVoucher {
1027            txid: voucher_txid,
1028            vout: 0,
1029            amount: 100_000,
1030            privkey: ISSUER_PRIVKEY.to_string(),
1031        };
1032        let mint = mint_token(ticker, None, 1_000, &voucher, "testnet4", 300, &mempool)
1033            .await
1034            .unwrap();
1035        let mint_txid = mempool.broadcast_tx(&mint.tx.raw_hex).await.unwrap();
1036
1037        // Persist the trail with the issuer secret + the broadcast txid.
1038        let mut stored = StoredTrail {
1039            ticker: mint.trail.ticker.clone(),
1040            name: mint.trail.name.clone(),
1041            supply: mint.trail.supply,
1042            privkey: ISSUER_PRIVKEY.to_string(),
1043            pubkey_base: mint.trail.pubkey_base.clone(),
1044            states: mint.trail.states.clone(),
1045            state_strings: mint.trail.state_strings.clone(),
1046            current_txid: mint_txid.clone(),
1047            current_vout: 0,
1048            current_amount: mint.trail.current_amount,
1049            network: mint.trail.network.clone(),
1050            date_created: "2026-06-13T00:00:00Z".into(),
1051        };
1052        stored.current_txid = mint_txid.clone();
1053        save_trail(&storage, &stored).await.unwrap();
1054
1055        // Register the genesis output scriptPubKey so anchor() can spend it.
1056        let genesis_xonly = {
1057            let chained = solid_pod_rs::mrc20::bt_derive_chained_pubkey(
1058                &issuer_pubkey(),
1059                std::slice::from_ref(&mint.state_jcs),
1060            )
1061            .unwrap();
1062            hex::encode(&chained[1..])
1063        };
1064        mempool.add_output(&mint_txid, 0, &format!("5120{genesis_xonly}"));
1065
1066        (storage, mempool, ticker.to_string())
1067    }
1068
1069    #[tokio::test]
1070    async fn block_anchorer_anchor_round_trip_and_self_verifies() {
1071        let (storage, mempool, ticker) = mint_and_store("ANCH").await;
1072        let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), storage.clone());
1073
1074        // Anchor a git commit SHA (the provenance write).
1075        let commit_sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9001122334";
1076        let anchor = anchorer
1077            .anchor(&ticker, commit_sha, "testnet4")
1078            .await
1079            .expect("anchor() must build + broadcast + persist");
1080
1081        assert_eq!(anchor.ticker, "ANCH");
1082        assert_eq!(anchor.state_hash, commit_sha);
1083        assert_eq!(anchor.vout, 0);
1084        assert!(anchor.blockheight.is_none());
1085        assert_eq!(anchor.network, "testnet4");
1086        assert!(anchor.pubkey.is_some());
1087        // The portable proof carries genesis + anchor state strings.
1088        assert_eq!(anchor.state_strings.len(), 2);
1089        // The recorded address is the genuine derivation from the proof.
1090        let derived = bt_address(
1091            anchor.pubkey.as_deref().unwrap(),
1092            &anchor.state_strings,
1093            "testnet4",
1094        )
1095        .unwrap();
1096        assert_eq!(anchor.address, derived);
1097
1098        // The trail was persisted with the new state appended + new txid.
1099        let reloaded = load_trail(&storage, "ANCH").await.unwrap().unwrap();
1100        assert_eq!(reloaded.states.len(), 2);
1101        assert_eq!(reloaded.current_txid, anchor.txid);
1102        assert_eq!(reloaded.states[1].anchor.as_deref(), Some(commit_sha));
1103
1104        // verify() ACCEPTS the produced anchor once a UTXO sits at its address.
1105        mempool.utxos.lock().unwrap().insert(
1106            anchor.address.clone(),
1107            vec![Utxo {
1108                txid: anchor.txid.clone(),
1109                vout: 0,
1110                value: 9_400,
1111                confirmed: false,
1112                block_height: None,
1113            }],
1114        );
1115        assert!(
1116            anchorer.verify(&anchor).await.unwrap(),
1117            "the anchor we just produced must verify against its own UTXO"
1118        );
1119    }
1120
1121    #[tokio::test]
1122    async fn block_anchorer_anchor_rejects_unminted_ticker() {
1123        let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
1124        let anchorer = MempoolBlockAnchorer::with_storage(FixtureMempool::empty(), storage);
1125        let err = anchorer
1126            .anchor("GHOST", "deadbeef", "testnet4")
1127            .await
1128            .unwrap_err();
1129        match err {
1130            ProvenanceError::Anchor(m) => assert!(m.contains("not minted")),
1131            other => panic!("expected not-minted error, got {other:?}"),
1132        }
1133    }
1134}