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`](crate::mempool::select_mempool_endpoint) resolves the
36//! base URL, [`infer_network`](crate::mempool::infer_network) classifies the Bitcoin network it serves, and
37//! [`log_mempool_selection`](crate::mempool::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`](crate::mempool::MempoolSelection::to_manifest_json) renders the same facts for a
41//! manifest (ADR-2007).
42
43use async_trait::async_trait;
44use serde::{Deserialize, Serialize};
45use std::time::Duration;
46
47use solid_pod_rs::bitcoin_tx::{
48    anchor_state, gitmark_advance, gitmark_genesis, GitmarkUpdate, MempoolBroadcast, TxoVoucher,
49    DEFAULT_FEE_SATS,
50};
51use solid_pod_rs::blocktrail::{verify_anchor_chain, TrailReport};
52use solid_pod_rs::mrc20::{bt_address, MempoolLookup, TxIn, TxInfo, TxOut, Utxo};
53use solid_pod_rs::payments::PaymentError;
54use solid_pod_rs::provenance::{BlockAnchorer, BlockTrailAnchor, ProvenanceError};
55
56/// Environment variable selecting the mempool REST base URL (JSS parity).
57pub const MEMPOOL_URL_ENV: &str = "JSS_PAY_MEMPOOL_URL";
58
59/// Default base URL — the mempool.space **testnet4** explorer. Matches the
60/// JSS default (`pay.js:243`, `mrc20.js:282`).
61pub const DEFAULT_MEMPOOL_URL: &str = "https://mempool.space/testnet4";
62
63/// Environment override for the mempool client's connect timeout, in seconds.
64pub const MEMPOOL_CONNECT_TIMEOUT_ENV: &str = "JSS_PAY_MEMPOOL_CONNECT_TIMEOUT_SECS";
65
66/// Environment override for the mempool client's total request timeout, in
67/// seconds.
68pub const MEMPOOL_TIMEOUT_ENV: &str = "JSS_PAY_MEMPOOL_TIMEOUT_SECS";
69
70/// Connect timeout when [`MEMPOOL_CONNECT_TIMEOUT_ENV`] is unset or invalid.
71const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 5;
72
73/// Total request timeout when [`MEMPOOL_TIMEOUT_ENV`] is unset or invalid.
74const DEFAULT_TIMEOUT_SECS: u64 = 20;
75
76/// A positive whole number of seconds from the environment, else `default`.
77fn env_secs(var: &str, default: u64) -> Duration {
78    let secs = std::env::var(var)
79        .ok()
80        .and_then(|v| v.trim().parse::<u64>().ok())
81        .filter(|s| *s > 0)
82        .unwrap_or(default);
83    Duration::from_secs(secs)
84}
85
86/// An HTTP client that gives up on a backend that stops answering.
87fn http_client(connect: Duration, total: Duration) -> reqwest::Client {
88    reqwest::Client::builder()
89        .connect_timeout(connect)
90        .timeout(total)
91        .build()
92        .unwrap_or_default()
93}
94
95// ---------------------------------------------------------------------------
96// Endpoint selection (ADR-2007) — pure, no I/O
97// ---------------------------------------------------------------------------
98//
99// The base URL alone does not tell an operator *why* the pod is pointed where
100// it is, nor which Bitcoin network that endpoint serves. A silent fall back to
101// the built-in testnet4 default could otherwise have the pod anchoring and
102// verifying against a chain nobody chose. The types below make the choice
103// explicit, classifiable and loggable without performing any I/O, so the
104// startup path can record it in the log and in a manifest.
105
106/// Where the mempool base URL came from.
107///
108/// [`MempoolConfigSource::Explicit`] means an operator supplied it (via
109/// [`MEMPOOL_URL_ENV`] or [`MempoolHttpClient::new`]);
110/// [`MempoolConfigSource::Default`] means nothing was configured and the
111/// built-in [`DEFAULT_MEMPOOL_URL`] was used. The distinction matters because a
112/// defaulted endpoint is an *unchosen* chain.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum MempoolConfigSource {
115    /// The operator supplied the base URL.
116    Explicit,
117    /// Nothing was configured; [`DEFAULT_MEMPOOL_URL`] was used.
118    Default,
119}
120
121impl MempoolConfigSource {
122    /// Lower-case wire name (`"explicit"` / `"default"`) used in logs and the
123    /// manifest JSON.
124    #[must_use]
125    pub fn as_str(&self) -> &'static str {
126        match self {
127            Self::Explicit => "explicit",
128            Self::Default => "default",
129        }
130    }
131}
132
133impl std::fmt::Display for MempoolConfigSource {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.write_str(self.as_str())
136    }
137}
138
139/// The Bitcoin network a mempool endpoint is understood to serve.
140///
141/// [`BitcoinNetwork::Unknown`] is deliberate: an unrecognised operator-supplied
142/// explorer is *not* assumed to be mainnet (or anything else). Guessing here
143/// would reintroduce exactly the silent-wrong-chain risk this type exists to
144/// remove.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum BitcoinNetwork {
147    /// Bitcoin mainnet.
148    Mainnet,
149    /// The legacy testnet3 network (`/testnet` or `/testnet3`).
150    Testnet3,
151    /// The testnet4 network (the crate default).
152    Testnet4,
153    /// The signet test network.
154    Signet,
155    /// A local regtest node (loopback host with no network in the path).
156    Regtest,
157    /// Not classifiable from the URL — treat as unverified.
158    Unknown,
159}
160
161impl BitcoinNetwork {
162    /// Lower-case wire name used in logs and the manifest JSON.
163    #[must_use]
164    pub fn as_str(&self) -> &'static str {
165        match self {
166            Self::Mainnet => "mainnet",
167            Self::Testnet3 => "testnet3",
168            Self::Testnet4 => "testnet4",
169            Self::Signet => "signet",
170            Self::Regtest => "regtest",
171            Self::Unknown => "unknown",
172        }
173    }
174}
175
176impl std::fmt::Display for BitcoinNetwork {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.write_str(self.as_str())
179    }
180}
181
182/// Classify the Bitcoin network a mempool base URL serves, purely from the URL.
183///
184/// The trailing path segment is matched case-insensitively against the
185/// mempool.space layout:
186///
187/// | Base URL                         | Network    |
188/// |----------------------------------|------------|
189/// | `https://mempool.space`          | `Mainnet`  |
190/// | `https://mempool.space/testnet4` | `Testnet4` |
191/// | `https://mempool.space/testnet`  | `Testnet3` |
192/// | `https://mempool.space/testnet3` | `Testnet3` |
193/// | `https://mempool.space/signet`   | `Signet`   |
194/// | `http://127.0.0.1:3006`          | `Regtest`  |
195/// | anything else                    | `Unknown`  |
196///
197/// A loopback host is taken to be a local regtest node *unless* its path names a
198/// network explicitly (`http://localhost:3006/signet` is `Signet`). Anything
199/// unrecognised is [`BitcoinNetwork::Unknown`] — never a guess.
200#[must_use]
201pub fn infer_network(base_url: &str) -> BitcoinNetwork {
202    let trimmed = base_url.trim().trim_end_matches('/');
203
204    // Split scheme / authority / path by hand: the base may legitimately be
205    // scheme-relative in a fixture, and a full URL parser would reject it.
206    let after_scheme = match trimmed.find("://") {
207        Some(i) => &trimmed[i + 3..],
208        None => trimmed,
209    };
210    let (authority, path) = match after_scheme.find('/') {
211        Some(i) => (&after_scheme[..i], &after_scheme[i + 1..]),
212        None => (after_scheme, ""),
213    };
214
215    // Last non-empty path segment, ignoring any query/fragment tail.
216    let path = path
217        .split(['?', '#'])
218        .next()
219        .unwrap_or("")
220        .trim_end_matches('/');
221    let last_segment = path.rsplit('/').find(|seg| !seg.is_empty()).unwrap_or("");
222
223    match last_segment.to_ascii_lowercase().as_str() {
224        "testnet4" => return BitcoinNetwork::Testnet4,
225        "testnet" | "testnet3" => return BitcoinNetwork::Testnet3,
226        "signet" => return BitcoinNetwork::Signet,
227        "regtest" => return BitcoinNetwork::Regtest,
228        "mainnet" | "bitcoin" => return BitcoinNetwork::Mainnet,
229        _ => {}
230    }
231
232    // Host (minus any userinfo / port) drives the remaining cases.
233    let host = authority.rsplit('@').next().unwrap_or(authority);
234    let host = if let Some(rest) = host.strip_prefix('[') {
235        // Bracketed IPv6 literal: keep the address, drop the port.
236        rest.split(']').next().unwrap_or(rest)
237    } else {
238        host.split(':').next().unwrap_or(host)
239    };
240    let host_lower = host.to_ascii_lowercase();
241
242    let is_loopback = host_lower == "localhost"
243        || host_lower == "::1"
244        || host_lower.starts_with("127.")
245        || host_lower.ends_with(".localhost");
246    if is_loopback {
247        // A local node with no network in the path is a regtest node.
248        return BitcoinNetwork::Regtest;
249    }
250
251    // A bare mempool.space-style host with no network path is mainnet.
252    if path.is_empty() && (host_lower == "mempool.space" || host_lower.ends_with(".mempool.space"))
253    {
254        return BitcoinNetwork::Mainnet;
255    }
256
257    BitcoinNetwork::Unknown
258}
259
260/// The resolved mempool endpoint: which URL, which chain, and why.
261///
262/// Produced by [`select_mempool_endpoint`] /
263/// [`select_mempool_endpoint_from_env`] and carried on [`MempoolHttpClient`] so
264/// the choice can be logged at startup ([`log_mempool_selection`]) and surfaced
265/// in a manifest ([`MempoolSelection::to_manifest_json`]).
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct MempoolSelection {
268    /// The base URL actually in use, trailing slash trimmed.
269    pub base_url: String,
270    /// The Bitcoin network inferred from `base_url`.
271    pub network: BitcoinNetwork,
272    /// Whether `base_url` was operator-supplied or the built-in default.
273    pub source: MempoolConfigSource,
274}
275
276impl MempoolSelection {
277    /// Render the selection as manifest JSON:
278    /// `{"base_url": …, "network": …, "source": "explicit"|"default"}`.
279    #[must_use]
280    pub fn to_manifest_json(&self) -> serde_json::Value {
281        serde_json::json!({
282            "base_url": self.base_url,
283            "network": self.network.as_str(),
284            "source": self.source.as_str(),
285        })
286    }
287}
288
289/// Resolve the mempool endpoint from an optional configured value.
290///
291/// A `configured` value that is `None`, empty or whitespace-only is treated as
292/// absent (matching the historical `JSS_PAY_MEMPOOL_URL` filter), yielding
293/// [`DEFAULT_MEMPOOL_URL`] with [`MempoolConfigSource::Default`]. Any trailing
294/// `/` is trimmed so `{base}/api/...` joins cleanly. Pure — no environment or
295/// network access.
296#[must_use]
297pub fn select_mempool_endpoint(configured: Option<&str>) -> MempoolSelection {
298    let (raw, source) = match configured.map(str::trim).filter(|v| !v.is_empty()) {
299        Some(v) => (v, MempoolConfigSource::Explicit),
300        None => (DEFAULT_MEMPOOL_URL, MempoolConfigSource::Default),
301    };
302    let base_url = raw.trim_end_matches('/').to_string();
303    let network = infer_network(&base_url);
304    MempoolSelection {
305        base_url,
306        network,
307        source,
308    }
309}
310
311/// Resolve the mempool endpoint from [`MEMPOOL_URL_ENV`], delegating the (pure)
312/// decision to [`select_mempool_endpoint`].
313#[must_use]
314pub fn select_mempool_endpoint_from_env() -> MempoolSelection {
315    let configured = std::env::var(MEMPOOL_URL_ENV).ok();
316    select_mempool_endpoint(configured.as_deref())
317}
318
319/// Record the selected endpoint in the startup log.
320///
321/// Emits exactly one `INFO` on target `solid_pod_rs_server::mempool` carrying
322/// the structured fields `base_url`, `network` and `source`, so an operator can
323/// tell from the log which chain the pod anchors and verifies against. When the
324/// network could not be classified, or the endpoint was defaulted rather than
325/// chosen, an additional `WARN` names [`MEMPOOL_URL_ENV`] as the fix.
326pub fn log_mempool_selection(sel: &MempoolSelection) {
327    tracing::info!(
328        target: "solid_pod_rs_server::mempool",
329        base_url = %sel.base_url,
330        network = sel.network.as_str(),
331        source = sel.source.as_str(),
332        "mempool endpoint selected"
333    );
334    if sel.network == BitcoinNetwork::Unknown || sel.source == MempoolConfigSource::Default {
335        tracing::warn!(
336            target: "solid_pod_rs_server::mempool",
337            base_url = %sel.base_url,
338            network = sel.network.as_str(),
339            source = sel.source.as_str(),
340            env_var = MEMPOOL_URL_ENV,
341            "pod is using an unverified or defaulted Bitcoin endpoint; anchors may be \
342             written to or verified against an unintended chain — set {} to pin the \
343             explorer, and therefore the network, explicitly",
344            MEMPOOL_URL_ENV
345        );
346    }
347}
348
349/// Emit [`log_mempool_selection`] at most once for the lifetime of the
350/// process.
351///
352/// `MempoolHttpClient::from_env` is called per request on the payment and
353/// provenance routes, so logging unconditionally there would repeat the
354/// startup record on every request. The selection is process-global (it comes
355/// from an environment variable), so one record is exactly the right number:
356/// it lands in the startup log the first time the endpoint is resolved, and
357/// `solid-pod-rs-server`'s `main` calls this explicitly at boot so the record
358/// appears even on a pod that never serves a payment route.
359///
360/// Callers that want the value rather than the log should read
361/// [`MempoolHttpClient::selection`] or call [`select_mempool_endpoint`].
362pub fn log_mempool_selection_once(sel: &MempoolSelection) {
363    static ONCE: std::sync::Once = std::sync::Once::new();
364    ONCE.call_once(|| log_mempool_selection(sel));
365}
366
367/// A [`MempoolLookup`] backed by the mempool.space REST API over `reqwest`.
368///
369/// Cheap to clone (holds an `Arc`-internal `reqwest::Client` and the base
370/// URL). Construct with [`MempoolHttpClient::from_env`] to honour
371/// `JSS_PAY_MEMPOOL_URL`, or [`MempoolHttpClient::new`] for an explicit base.
372#[derive(Debug, Clone)]
373pub struct MempoolHttpClient {
374    client: reqwest::Client,
375    /// Base URL with any trailing slash trimmed (so `{base}/api/...` joins
376    /// cleanly regardless of how the operator wrote the env value).
377    base: String,
378    /// The resolved endpoint choice — URL, inferred network, and where the URL
379    /// came from — so the selection is recordable rather than an invisible
380    /// default (ADR-2007).
381    selection: MempoolSelection,
382}
383
384impl MempoolHttpClient {
385    /// Construct a client against an explicit base URL (e.g.
386    /// `https://mempool.space/testnet4`). A trailing `/` is trimmed.
387    #[must_use]
388    pub fn new(base_url: impl Into<String>) -> Self {
389        let raw = base_url.into();
390        let base = raw.trim_end_matches('/').to_string();
391        // An explicit constructor argument is operator-chosen by definition,
392        // even when it happens to equal DEFAULT_MEMPOOL_URL.
393        let selection = MempoolSelection {
394            network: infer_network(&base),
395            base_url: base,
396            source: MempoolConfigSource::Explicit,
397        };
398        Self::from_selection(selection)
399    }
400
401    /// Construct from an already-resolved [`MempoolSelection`]. Does not log —
402    /// the caller owns whether this choice is recorded (see
403    /// [`log_mempool_selection`]).
404    #[must_use]
405    pub fn from_selection(selection: MempoolSelection) -> Self {
406        Self {
407            client: http_client(
408                env_secs(MEMPOOL_CONNECT_TIMEOUT_ENV, DEFAULT_CONNECT_TIMEOUT_SECS),
409                env_secs(MEMPOOL_TIMEOUT_ENV, DEFAULT_TIMEOUT_SECS),
410            ),
411            base: selection.base_url.clone(),
412            selection,
413        }
414    }
415
416    /// Replace the connect and total request timeouts (the defaults come from
417    /// [`MEMPOOL_CONNECT_TIMEOUT_ENV`] / [`MEMPOOL_TIMEOUT_ENV`]). The
418    /// anchoring locks are held across these calls, so a backend that never
419    /// answers must be cut off.
420    #[must_use]
421    pub fn with_timeouts(mut self, connect: Duration, total: Duration) -> Self {
422        self.client = http_client(connect, total);
423        self
424    }
425
426    /// Construct from `JSS_PAY_MEMPOOL_URL`, falling back to
427    /// [`DEFAULT_MEMPOOL_URL`] (testnet4).
428    ///
429    /// The resolved endpoint is logged exactly once via
430    /// [`log_mempool_selection`], so the startup log always records which
431    /// explorer — and therefore which Bitcoin network — the pod is using, with
432    /// a warning when that endpoint was defaulted or is unclassifiable.
433    #[must_use]
434    pub fn from_env() -> Self {
435        let selection = select_mempool_endpoint_from_env();
436        log_mempool_selection_once(&selection);
437        Self::from_selection(selection)
438    }
439
440    /// The configured base URL (trailing slash trimmed).
441    #[must_use]
442    pub fn base_url(&self) -> &str {
443        &self.base
444    }
445
446    /// The resolved endpoint selection: base URL, inferred Bitcoin network, and
447    /// whether the URL was operator-supplied or defaulted.
448    #[must_use]
449    pub fn selection(&self) -> &MempoolSelection {
450        &self.selection
451    }
452
453    /// Check whether a transaction is known without collapsing an HTTP 404
454    /// into the same result as an ambiguous transport/server failure. Payment
455    /// intent recovery may compensate a debit only for `Ok(false)`.
456    pub async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError> {
457        let url = format!("{}/api/tx/{txid}", self.base);
458        let resp = self
459            .client
460            .get(&url)
461            .send()
462            .await
463            .map_err(|e| PaymentError::InvalidState(format!("mempool request failed: {e}")))?;
464        if resp.status() == reqwest::StatusCode::NOT_FOUND {
465            return Ok(false);
466        }
467        if !resp.status().is_success() {
468            return Err(PaymentError::InvalidState(format!(
469                "mempool API error: {} for {url}",
470                resp.status().as_u16()
471            )));
472        }
473        Ok(true)
474    }
475
476    /// GET `url`, returning the body text on a 2xx, or a fail-closed
477    /// [`PaymentError::InvalidState`] describing the transport/status error.
478    async fn get_text(&self, url: &str) -> Result<String, PaymentError> {
479        let resp = self
480            .client
481            .get(url)
482            .send()
483            .await
484            .map_err(|e| PaymentError::InvalidState(format!("mempool request failed: {e}")))?;
485        let status = resp.status();
486        if !status.is_success() {
487            return Err(PaymentError::InvalidState(format!(
488                "mempool API error: {} for {url}",
489                status.as_u16()
490            )));
491        }
492        resp.text()
493            .await
494            .map_err(|e| PaymentError::InvalidState(format!("mempool body read failed: {e}")))
495    }
496
497    /// POST `body` as `text/plain` to `url`, returning the response body on a
498    /// 2xx (the txid, for `/api/tx`) or a fail-closed
499    /// [`PaymentError::InvalidState`]. Mirrors JSS `broadcastTx`
500    /// (`token.js:176-187`).
501    async fn post_text(&self, url: &str, body: &str) -> Result<String, PaymentError> {
502        let resp = self
503            .client
504            .post(url)
505            .header("Content-Type", "text/plain")
506            .body(body.to_string())
507            .send()
508            .await
509            .map_err(|e| PaymentError::InvalidState(format!("mempool broadcast failed: {e}")))?;
510        let status = resp.status();
511        let text = resp
512            .text()
513            .await
514            .map_err(|e| PaymentError::InvalidState(format!("mempool body read failed: {e}")))?;
515        if !status.is_success() {
516            return Err(PaymentError::InvalidState(format!(
517                "broadcast rejected ({}): {text}",
518                status.as_u16()
519            )));
520        }
521        Ok(text.trim().to_string())
522    }
523}
524
525// ── Wire shapes (mempool.space schema) ──────────────────────────────────
526
527/// Nested `status` object on UTXO/tx responses.
528#[derive(Debug, Deserialize, Default)]
529struct StatusWire {
530    #[serde(default)]
531    confirmed: bool,
532    #[serde(default)]
533    block_height: Option<u64>,
534}
535
536/// One element of `GET /api/address/{addr}/utxo`.
537#[derive(Debug, Deserialize)]
538struct UtxoWire {
539    txid: String,
540    vout: u32,
541    #[serde(default)]
542    value: u64,
543    #[serde(default)]
544    status: StatusWire,
545}
546
547impl From<UtxoWire> for Utxo {
548    fn from(w: UtxoWire) -> Self {
549        Utxo {
550            txid: w.txid,
551            vout: w.vout,
552            value: w.value,
553            confirmed: w.status.confirmed,
554            block_height: w.status.block_height,
555        }
556    }
557}
558
559/// One element of a tx's `vout` array.
560#[derive(Debug, Deserialize, Default)]
561struct TxOutWire {
562    #[serde(default)]
563    value: u64,
564    #[serde(default)]
565    scriptpubkey: Option<String>,
566    #[serde(default)]
567    scriptpubkey_address: Option<String>,
568}
569
570impl From<TxOutWire> for TxOut {
571    fn from(w: TxOutWire) -> Self {
572        TxOut {
573            value: w.value,
574            scriptpubkey: w.scriptpubkey,
575            scriptpubkey_address: w.scriptpubkey_address,
576        }
577    }
578}
579
580/// One element of a tx's `vin` array: the outpoint it spends (a coinbase
581/// input carries the null outpoint).
582#[derive(Debug, Deserialize)]
583struct TxInWire {
584    #[serde(default)]
585    txid: String,
586    #[serde(default)]
587    vout: u32,
588}
589
590/// Shape of `GET /api/tx/{txid}`.
591#[derive(Debug, Deserialize)]
592struct TxWire {
593    txid: String,
594    #[serde(default)]
595    vin: Vec<TxInWire>,
596    #[serde(default)]
597    vout: Vec<TxOutWire>,
598    #[serde(default)]
599    status: StatusWire,
600}
601
602impl From<TxWire> for TxInfo {
603    fn from(w: TxWire) -> Self {
604        TxInfo {
605            txid: w.txid,
606            vin: w
607                .vin
608                .into_iter()
609                .map(|i| TxIn {
610                    txid: i.txid,
611                    vout: i.vout,
612                })
613                .collect(),
614            vout: w.vout.into_iter().map(TxOut::from).collect(),
615            confirmed: w.status.confirmed,
616            block_height: w.status.block_height,
617        }
618    }
619}
620
621#[async_trait(?Send)]
622impl MempoolLookup for MempoolHttpClient {
623    async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
624        let url = format!("{}/api/address/{address}/utxo", self.base);
625        let body = self.get_text(&url).await?;
626        let wire: Vec<UtxoWire> = serde_json::from_str(&body)
627            .map_err(|e| PaymentError::InvalidState(format!("malformed utxo JSON: {e}")))?;
628        Ok(wire.into_iter().map(Utxo::from).collect())
629    }
630
631    async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
632        let url = format!("{}/api/tx/{txid}", self.base);
633        let body = self.get_text(&url).await?;
634        let wire: TxWire = serde_json::from_str(&body)
635            .map_err(|e| PaymentError::InvalidState(format!("malformed tx JSON: {e}")))?;
636        Ok(TxInfo::from(wire))
637    }
638}
639
640#[async_trait(?Send)]
641impl MempoolBroadcast for MempoolHttpClient {
642    async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
643        let url = format!("{}/api/tx", self.base);
644        self.post_text(&url, raw_hex).await
645    }
646}
647
648// ---------------------------------------------------------------------------
649// BlockAnchorer::verify — the portable-proof read-side (provenance §2.2)
650// ---------------------------------------------------------------------------
651
652/// A [`BlockAnchorer`] implementing **both** sides over a transport that can
653/// look up UTXOs ([`MempoolLookup`]) and broadcast transactions
654/// ([`MempoolBroadcast`]). Generic over that transport so a fixture drives it
655/// in tests and [`MempoolHttpClient`] drives it in production — without
656/// changing the logic.
657///
658/// - `verify` re-derives the expected taproot address from the anchor's
659///   *portable proof* (`pubkey` + `state_strings`) via [`bt_address`], rejects
660///   a forged `address`, then walks the trail back from the anchor's outpoint
661///   ([`verify_anchor_report`]): every mark must exist, carry the key its
662///   prefix of states derives and spend the mark before it. No pod trust
663///   required, and an anchor stays verifiable after later marks spend it.
664///
665/// This anchorer serves the MRC20 trails a pod has already issued (their
666/// marks and states stay as they are); new trails use [`GitmarkAnchorer`].
667/// - `anchor` (Phase 4) loads the named trail from storage, appends an MRC20
668///   state notarising `state_hash` (via
669///   [`anchor_state`], broadcasts the
670///   anchoring tx, persists the updated trail, and returns the
671///   [`BlockTrailAnchor`] (txid/vout/address/state_strings/pubkey). It requires
672///   a `storage` handle (set via [`MempoolBlockAnchorer::with_storage`]); the
673///   verify-only constructor [`MempoolBlockAnchorer::new`] leaves it `None` and
674///   `anchor()` then errors with a clear message.
675#[derive(Clone)]
676pub struct MempoolBlockAnchorer<M: MempoolLookup + MempoolBroadcast + Send + Sync> {
677    lookup: M,
678    storage: Option<std::sync::Arc<dyn solid_pod_rs::storage::Storage>>,
679}
680
681impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> MempoolBlockAnchorer<M> {
682    /// Wrap a transport as a **verify-capable** [`BlockAnchorer`]. `anchor()`
683    /// is unavailable (no storage) and returns an error explaining that
684    /// [`with_storage`](Self::with_storage) is required.
685    pub fn new(lookup: M) -> Self {
686        Self {
687            lookup,
688            storage: None,
689        }
690    }
691
692    /// Wrap a transport + pod storage as a **fully-capable** [`BlockAnchorer`]
693    /// (both `verify` and `anchor`). The `storage` backs the trail load/save at
694    /// `/.well-known/token/{ticker}.json`.
695    pub fn with_storage(
696        lookup: M,
697        storage: std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
698    ) -> Self {
699        Self {
700            lookup,
701            storage: Some(storage),
702        }
703    }
704
705    /// Borrow the underlying transport (e.g. for a one-off `address_utxos`).
706    pub fn lookup(&self) -> &M {
707        &self.lookup
708    }
709}
710
711/// Marks an anchor whose broadcast outcome is unknown. Once the intent is
712/// recorded the transaction may be on-chain, and the next advance settles it
713/// from the chain, so the caller has been served and must not be refunded.
714const PENDING_ANCHOR_PREFIX: &str = "anchor pending: ";
715
716/// The error for an anchor that failed after its intent was recorded.
717fn pending_anchor(detail: String) -> ProvenanceError {
718    ProvenanceError::Anchor(format!("{PENDING_ANCHOR_PREFIX}{detail}"))
719}
720
721/// Whether `e` is an anchor left pending (see [`pending_anchor`]) rather than
722/// one that definitely did not go out.
723#[cfg(any(feature = "git", test))]
724pub(crate) fn is_pending_anchor(e: &ProvenanceError) -> bool {
725    matches!(e, ProvenanceError::Anchor(m) if m.starts_with(PENDING_ANCHOR_PREFIX))
726}
727
728#[async_trait(?Send)]
729impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> BlockAnchorer for MempoolBlockAnchorer<M> {
730    /// Append one MRC20 state anchoring `state_hash` under `ticker`, build +
731    /// broadcast the anchoring tx, persist the updated trail, and return the
732    /// produced [`BlockTrailAnchor`]. This is the expensive-tier write the
733    /// provenance design hinges on (ADR-059 §2.2, master-plan Phase 4).
734    ///
735    /// `network` is honoured as a guard: it must match the trail's own network
736    /// (the trail's chained-key addresses are network-bound). The returned
737    /// anchor's `vout` is `0` (the anchoring tx pays the next chained-key UTXO
738    /// at output 0); `blockheight` is `None` until the tx confirms.
739    async fn anchor(
740        &self,
741        ticker: &str,
742        state_hash: &str,
743        network: &str,
744    ) -> Result<BlockTrailAnchor, ProvenanceError> {
745        use crate::trail_store::{load_trail, save_trail};
746
747        let storage = self.storage.as_ref().ok_or_else(|| {
748            ProvenanceError::Anchor(
749                "anchor() requires storage; construct with MempoolBlockAnchorer::with_storage"
750                    .into(),
751            )
752        })?;
753
754        // One advance of this trail at a time (anchorers are per-request).
755        let _guard = crate::trail_store::lock_path(&crate::trail_store::trail_path(ticker)).await;
756
757        settle_trail_intent(&self.lookup, storage, ticker).await?;
758
759        // Load the trail that will carry the anchor (JSS `loadTrail`).
760        let mut stored = load_trail(storage, ticker)
761            .await
762            .map_err(|e| ProvenanceError::Anchor(format!("load trail {ticker}: {e}")))?
763            .ok_or_else(|| {
764                ProvenanceError::Anchor(format!("trail {ticker} not minted on this pod"))
765            })?;
766
767        if stored.network != network {
768            return Err(ProvenanceError::Anchor(format!(
769                "network mismatch: trail is {}, requested {network}",
770                stored.network
771            )));
772        }
773
774        // Build the anchoring tx (appends a state notarising `state_hash`).
775        let public = stored.to_public();
776        let update = anchor_state(
777            &public,
778            &stored.privkey,
779            state_hash,
780            DEFAULT_FEE_SATS,
781            &self.lookup,
782        )
783        .await
784        .map_err(|e| ProvenanceError::Anchor(format!("build anchoring tx: {e}")))?;
785
786        // The trail once the tx is in, with the txid the signed tx has as its
787        // new currentTxid (so the next anchor/transfer spends this output).
788        let expected = solid_pod_rs::bitcoin_tx::decode_tx_info(&update.tx.raw_hex)
789            .map_err(|e| ProvenanceError::Anchor(format!("decode anchoring tx: {e}")))?
790            .txid;
791        let mut appended = update.trail.clone();
792        appended.current_txid = expected.clone();
793        stored.merge_public(&appended);
794        stored.current_txid = expected.clone();
795        stored.current_vout = 0;
796
797        // Record the broadcast first: a lost response or a failed save is
798        // then settled from the chain by the next advance, not left as a
799        // stored head that points at a spent output.
800        let intent_path = anchor_intent_path(ticker);
801        let body = serde_json::to_vec(&PendingAnchor {
802            txid: expected.clone(),
803            raw_hex: update.tx.raw_hex.clone(),
804            stored: stored.clone(),
805        })
806        .map_err(|e| ProvenanceError::Anchor(format!("serialise pending anchor: {e}")))?;
807        storage
808            .put(&intent_path, bytes::Bytes::from(body), "application/json")
809            .await
810            .map_err(|e| ProvenanceError::Anchor(format!("record pending anchor: {e}")))?;
811
812        // Broadcast (JSS `broadcastTx`). The returned txid IS the anchoring tx.
813        let txid = self
814            .lookup
815            .broadcast_tx(&update.tx.raw_hex)
816            .await
817            .map_err(|e| pending_anchor(format!("broadcast anchoring tx: {e}")))?;
818        if txid != expected {
819            return Err(pending_anchor(format!(
820                "mempool returned txid {txid} for an anchor that is {expected}"
821            )));
822        }
823
824        // Once the broadcast is accepted the anchor is on-chain, so a failed
825        // save is not a failed anchor: the intent stays and the next advance
826        // settles it. The caller must not be refunded for it.
827        match save_trail(storage, &stored).await {
828            Ok(()) => {
829                if let Err(e) = storage.delete(&intent_path).await {
830                    // Settling it later is a no-op: the trail is already saved.
831                    tracing::warn!(%ticker, "anchor intent not cleared: {e}");
832                }
833            }
834            Err(e) => tracing::error!(
835                %ticker, %txid,
836                "anchor broadcast accepted but trail save failed; the next advance settles it: {e}"
837            ),
838        }
839
840        Ok(BlockTrailAnchor {
841            ticker: ticker.to_string(),
842            state_hash: state_hash.to_string(),
843            txid,
844            vout: 0,
845            address: update.address,
846            network: network.to_string(),
847            blockheight: None,
848            state_strings: appended.state_strings,
849            pubkey: Some(stored.pubkey_base),
850        })
851    }
852
853    async fn verify(&self, anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
854        Ok(verify_anchor_report(&self.lookup, anchor)
855            .await?
856            .is_some_and(|r| r.is_intact()))
857    }
858}
859
860impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> MempoolBlockAnchorer<M> {
861    /// The per-link report behind [`BlockAnchorer::verify`]: `None` when the
862    /// anchor carries no portable proof or its recorded address is not the
863    /// derivation; otherwise every mark, walked back from the anchor's
864    /// outpoint, with the [`TrailVerdict`](solid_pod_rs::blocktrail::TrailVerdict)
865    /// (`verified` once every mark is confirmed).
866    pub async fn verify_report(
867        &self,
868        anchor: &BlockTrailAnchor,
869    ) -> Result<Option<TrailReport>, ProvenanceError> {
870        verify_anchor_report(&self.lookup, anchor).await
871    }
872}
873
874/// Check an anchor's portable proof and walk its trail back from the anchor's
875/// outpoint: the read side both anchorers share.
876///
877/// `Ok(None)` when there is nothing to check against (no `pubkey`, no
878/// `state_strings`) or the recorded `address` is not the one the proof
879/// derives (a forged anchor). Otherwise the
880/// [`verify_anchor_chain`]
881/// report: every mark recomputed from the proof and compared with its output
882/// on-chain, each spending the one before it. A head still in the mempool is
883/// intact but not yet verified.
884///
885/// # Errors
886///
887/// [`ProvenanceError::Anchor`] when the proof cannot be derived at all (a
888/// malformed key, a zero tweak).
889pub async fn verify_anchor_report(
890    lookup: &dyn MempoolLookup,
891    anchor: &BlockTrailAnchor,
892) -> Result<Option<TrailReport>, ProvenanceError> {
893    // The portable proof requires both the issuer pubkey and the state
894    // strings. Absent either, there is nothing to independently re-derive
895    // against: not verifiable (None, not an error).
896    let Some(pubkey) = anchor.pubkey.as_deref() else {
897        return Ok(None);
898    };
899    if anchor.state_strings.is_empty() {
900        return Ok(None);
901    }
902
903    // Re-derive the taproot address from the proof and reject a forged
904    // `address` field (the recorded address must equal the derivation).
905    let derived = bt_address(pubkey, &anchor.state_strings, &anchor.network)
906        .map_err(|e| ProvenanceError::Anchor(format!("address re-derivation failed: {e}")))?;
907    if derived != anchor.address {
908        return Ok(None);
909    }
910
911    // Every link: the anchor's own mark, then each mark it spends back to
912    // genesis, each the key its prefix of states derives.
913    verify_anchor_chain(
914        pubkey,
915        &anchor.state_strings,
916        &anchor.txid,
917        anchor.vout,
918        lookup,
919    )
920    .await
921    .map(Some)
922    .map_err(|e| ProvenanceError::Anchor(format!("anchor chain walk failed: {e}")))
923}
924
925// ---------------------------------------------------------------------------
926// Pending broadcasts: kept until the trail that carries them is saved
927// ---------------------------------------------------------------------------
928
929/// Whether a broadcast error says the node already has this very transaction
930/// (in its mempool, in a block, or its outputs in the UTXO set). Anything else
931/// is not a statement about the transaction and is never read as one.
932fn already_known(e: &PaymentError) -> bool {
933    let msg = e.to_string().to_lowercase().replace('-', " ");
934    msg.contains("already in mempool")
935        || msg.contains("already in block chain")
936        || msg.contains("already known")
937        || msg.contains("already in utxo set")
938        || msg.contains("outputs already in")
939}
940
941/// Settle a recorded broadcast that the backend does not report. "Not found"
942/// is not "never sent": a backend that took the transaction but has not
943/// indexed it says the same. So send it again; the same bytes are harmless.
944///
945/// - `Ok(true)`: the node has the transaction (sent now, or already known).
946/// - `Ok(false)`: the node refused it (a 4xx that is not "already known") **and**
947///   it cannot land: the output it spends is shown unspent, or `head` (the
948///   outpoint the trail's newest mark now is) is no longer that output, so
949///   something else replaced it. The caller may drop its record.
950/// - `Err`: nothing was learned (transport, 5xx, a refusal with no proof the
951///   input is free); the caller keeps its record and refuses to advance.
952async fn settle_broadcast<M: MempoolLookup + MempoolBroadcast + ?Sized>(
953    lookup: &M,
954    txid: &str,
955    raw_hex: &str,
956    head: Option<(&str, u32)>,
957) -> Result<bool, ProvenanceError> {
958    let unresolved = |why: &dyn std::fmt::Display| {
959        ProvenanceError::Anchor(format!(
960            "a previous mark ({txid}) is unresolved, refusing to advance: {why}"
961        ))
962    };
963    let refusal = match lookup.broadcast_tx(raw_hex).await {
964        Ok(t) if t == txid => return Ok(true),
965        Ok(t) => {
966            return Err(ProvenanceError::Anchor(format!(
967                "mempool returned txid {t} for a mark that is {txid}"
968            )))
969        }
970        Err(e) if already_known(&e) => return Ok(true),
971        Err(e) if e.to_string().contains("broadcast rejected (4") => e,
972        Err(e) => return Err(unresolved(&e)),
973    };
974    let spent = solid_pod_rs::bitcoin_tx::decode_tx_info(raw_hex)
975        .map_err(|e| unresolved(&e))?
976        .vin
977        .into_iter()
978        .next()
979        .ok_or_else(|| unresolved(&"it spends nothing"))?;
980    // Replaced: the trail already moved off the output this mark spends
981    // (and is not this mark), so this mark can never confirm.
982    if head.is_some_and(|(t, v)| (t, v) != (spent.txid.as_str(), spent.vout) && t != txid) {
983        return Ok(false);
984    }
985    // Unspent: the node has just refused the mark, so a lagging index cannot
986    // be hiding it; the output it spends is still there to be spent again.
987    let prev = lookup.tx(&spent.txid).await.map_err(|e| unresolved(&e))?;
988    let address = prev
989        .vout
990        .get(spent.vout as usize)
991        .and_then(|o| o.scriptpubkey_address.clone())
992        .ok_or_else(|| unresolved(&refusal))?;
993    let utxos = lookup
994        .address_utxos(&address)
995        .await
996        .map_err(|e| unresolved(&e))?;
997    if utxos
998        .iter()
999        .any(|u| u.txid == spent.txid && u.vout == spent.vout)
1000    {
1001        Ok(false)
1002    } else {
1003        Err(unresolved(&refusal))
1004    }
1005}
1006
1007/// An intent is only acted on if its signed transaction really is the one it
1008/// names and, when it would move the stored trail forward (`behind`), spends
1009/// the trail's stored head. A tampered or mismatched record is refused rather
1010/// than written over the trail.
1011fn verify_intent_tx(
1012    txid: &str,
1013    raw_hex: &str,
1014    head: Option<(&str, u32)>,
1015    behind: bool,
1016) -> Result<(), ProvenanceError> {
1017    let bad =
1018        |why: String| ProvenanceError::Anchor(format!("pending intent {txid} refused: {why}"));
1019    let info = solid_pod_rs::bitcoin_tx::decode_tx_info(raw_hex)
1020        .map_err(|e| bad(format!("undecodable transaction: {e}")))?;
1021    if info.txid != txid {
1022        return Err(bad(format!("its transaction is {}", info.txid)));
1023    }
1024    if let (true, Some((head_txid, head_vout))) = (behind, head) {
1025        let spends_head = info
1026            .vin
1027            .first()
1028            .is_some_and(|i| i.txid == head_txid && i.vout == head_vout);
1029        if !spends_head {
1030            return Err(bad("it does not spend the stored head".into()));
1031        }
1032    }
1033    Ok(())
1034}
1035
1036/// A git-mark intent as stored: [`GitmarkIntent`](crate::trail_store::GitmarkIntent)
1037/// plus the signed transaction, so an unresolved mark can be sent again.
1038/// Intents written before `rawHex` existed read back with `None`.
1039#[derive(Serialize, Deserialize)]
1040#[serde(rename_all = "camelCase")]
1041struct PendingMark {
1042    #[serde(flatten)]
1043    intent: crate::trail_store::GitmarkIntent,
1044    #[serde(default)]
1045    raw_hex: Option<String>,
1046}
1047
1048/// Read the pending mark of git-mark trail `name`, if any.
1049async fn load_pending_mark(
1050    storage: &std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
1051    name: &str,
1052) -> Result<Option<PendingMark>, ProvenanceError> {
1053    use solid_pod_rs::PodError;
1054    match storage
1055        .get(&crate::trail_store::gitmark_intent_path(name))
1056        .await
1057    {
1058        Ok((bytes, _)) => serde_json::from_slice(&bytes).map(Some).map_err(|e| {
1059            ProvenanceError::Anchor(format!("read pending mark: malformed git-mark intent: {e}"))
1060        }),
1061        Err(PodError::NotFound(_)) => Ok(None),
1062        Err(e) => Err(ProvenanceError::Anchor(format!("read pending mark: {e}"))),
1063    }
1064}
1065
1066/// Write the pending mark of a git-mark trail.
1067async fn save_pending_mark(
1068    storage: &std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
1069    pending: &PendingMark,
1070) -> Result<(), String> {
1071    let body = serde_json::to_vec(pending).map_err(|e| e.to_string())?;
1072    storage
1073        .put(
1074            &crate::trail_store::gitmark_intent_path(&pending.intent.stored.name),
1075            bytes::Bytes::from(body),
1076            "application/json",
1077        )
1078        .await
1079        .map(|_| ())
1080        .map_err(|e| e.to_string())
1081}
1082
1083/// An MRC20 anchor about to be broadcast: the signed transaction and the
1084/// trail as it stands once that transaction is in.
1085#[derive(Serialize, Deserialize)]
1086#[serde(rename_all = "camelCase")]
1087struct PendingAnchor {
1088    txid: String,
1089    raw_hex: String,
1090    stored: crate::trail_store::StoredTrail,
1091}
1092
1093/// Pod-storage path of an MRC20 trail's pending anchor. It holds the trail's
1094/// secret, so it sits under `/.well-known/token/` beside the trail file.
1095fn anchor_intent_path(ticker: &str) -> String {
1096    format!("/.well-known/token/{}/intent.json", ticker.to_lowercase())
1097}
1098
1099/// Settle an interrupted advance of the MRC20 trail `ticker`: the previous
1100/// anchor wrote its intent and did not clear it. The transaction is looked up
1101/// and, if the backend does not know it, sent again; once it is in, the
1102/// intent's trail becomes the stored one (unless a later write already did).
1103/// Caller holds the trail's [`lock_path`](crate::trail_store::lock_path).
1104///
1105/// # Errors
1106///
1107/// [`ProvenanceError::Anchor`] when the pending anchor cannot be resolved; the
1108/// intent is kept and no advance may be built on the old head.
1109pub(crate) async fn settle_trail_intent<M: MempoolLookup + MempoolBroadcast + ?Sized>(
1110    lookup: &M,
1111    storage: &std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
1112    ticker: &str,
1113) -> Result<(), ProvenanceError> {
1114    use crate::trail_store::{load_trail, save_trail};
1115    use solid_pod_rs::PodError;
1116    let err =
1117        |what: &str, e: &dyn std::fmt::Display| ProvenanceError::Anchor(format!("{what}: {e}"));
1118    let path = anchor_intent_path(ticker);
1119    let pending: PendingAnchor = match storage.get(&path).await {
1120        Ok((bytes, _)) => {
1121            serde_json::from_slice(&bytes).map_err(|e| err("malformed pending anchor", &e))?
1122        }
1123        Err(PodError::NotFound(_)) => return Ok(()),
1124        Err(e) => return Err(err("read pending anchor", &e)),
1125    };
1126    let stored = load_trail(storage, ticker)
1127        .await
1128        .map_err(|e| err("load trail", &e))?;
1129    verify_intent_tx(
1130        &pending.txid,
1131        &pending.raw_hex,
1132        stored
1133            .as_ref()
1134            .map(|t| (t.current_txid.as_str(), t.current_vout)),
1135        stored.as_ref().map_or(0, |t| t.states.len()) < pending.stored.states.len(),
1136    )?;
1137    let landed = lookup.tx(&pending.txid).await.is_ok()
1138        || settle_broadcast(
1139            lookup,
1140            &pending.txid,
1141            &pending.raw_hex,
1142            stored
1143                .as_ref()
1144                .map(|t| (t.current_txid.as_str(), t.current_vout)),
1145        )
1146        .await?;
1147    if landed && stored.map_or(0, |t| t.states.len()) < pending.stored.states.len() {
1148        save_trail(storage, &pending.stored)
1149            .await
1150            .map_err(|e| err("save recovered trail", &e))?;
1151    }
1152    match storage.delete(&path).await {
1153        Ok(()) | Err(PodError::NotFound(_)) => Ok(()),
1154        Err(e) => Err(err("clear pending anchor", &e)),
1155    }
1156}
1157
1158// ---------------------------------------------------------------------------
1159// GitmarkAnchorer — new trails: the git-mark profile
1160// ---------------------------------------------------------------------------
1161
1162/// Whether the mempool knows a transaction, with a definite "no" kept apart
1163/// from a transport failure ([`MempoolLookup::tx`] cannot tell them apart).
1164/// [`GitmarkAnchorer`] resolves an interrupted broadcast with it.
1165#[async_trait(?Send)]
1166pub trait MempoolTxPresence {
1167    /// `Ok(true)` when `txid` is known, `Ok(false)` only on a definite
1168    /// not-found, `Err` for anything ambiguous.
1169    async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError>;
1170}
1171
1172#[async_trait(?Send)]
1173impl MempoolTxPresence for MempoolHttpClient {
1174    async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError> {
1175        MempoolHttpClient::transaction_exists(self, txid).await
1176    }
1177}
1178
1179/// A [`BlockAnchorer`] over a git-mark trail: each anchored state is the
1180/// commit hash itself, as text (blocktrails/git-mark b852d7d), with no MRC20
1181/// wrapper.
1182///
1183/// This is the profile new trails use. The MRC20 trails a pod has already
1184/// issued keep [`MempoolBlockAnchorer`] and its `urn:mono:op:anchor` states:
1185/// their marks are on-chain and their keys follow from those states, so they
1186/// are never moved to another profile.
1187///
1188/// - [`genesis`](Self::genesis) starts a trail: a funding voucher is spent to
1189///   the first mark, the voucher key's point tweaked by the first commit; that
1190///   key is the trail's base key.
1191/// - `anchor` spends the newest mark to the next, tweaked by the commit
1192///   ([`gitmark_advance`]), broadcasts it and saves the trail.
1193/// - `verify` walks the trail back from the anchor's outpoint, as
1194///   [`MempoolBlockAnchorer`] does.
1195///
1196/// The trail is kept at `/.well-known/gitmark/{name}.json` with its base
1197/// secret (as MRC20 trails keep theirs, see [`crate::trail_store`]), and its
1198/// public `blocktrails.json` (no secret) is written beside it at
1199/// `/.well-known/gitmark/{name}/blocktrails.json`, where a verifier such as
1200/// blocktrails/verify reads it.
1201#[derive(Clone)]
1202pub struct GitmarkAnchorer<M: MempoolLookup + MempoolBroadcast + MempoolTxPresence + Send + Sync> {
1203    lookup: M,
1204    storage: Option<std::sync::Arc<dyn solid_pod_rs::storage::Storage>>,
1205}
1206
1207impl<M: MempoolLookup + MempoolBroadcast + MempoolTxPresence + Send + Sync> GitmarkAnchorer<M> {
1208    /// Wrap a transport as a **verify-capable** anchorer; `anchor()` and
1209    /// [`genesis`](Self::genesis) need [`with_storage`](Self::with_storage).
1210    pub fn new(lookup: M) -> Self {
1211        Self {
1212            lookup,
1213            storage: None,
1214        }
1215    }
1216
1217    /// Wrap a transport and pod storage as a fully-capable anchorer.
1218    pub fn with_storage(
1219        lookup: M,
1220        storage: std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
1221    ) -> Self {
1222        Self {
1223            lookup,
1224            storage: Some(storage),
1225        }
1226    }
1227
1228    /// Borrow the underlying transport.
1229    pub fn lookup(&self) -> &M {
1230        &self.lookup
1231    }
1232
1233    fn storage(
1234        &self,
1235    ) -> Result<&std::sync::Arc<dyn solid_pod_rs::storage::Storage>, ProvenanceError> {
1236        self.storage.as_ref().ok_or_else(|| {
1237            ProvenanceError::Anchor(
1238                "a git-mark trail needs storage; construct with GitmarkAnchorer::with_storage"
1239                    .into(),
1240            )
1241        })
1242    }
1243
1244    /// Settle an interrupted advance of trail `name`: the previous call wrote
1245    /// its intent (the txid it would broadcast and the trail with it) and did
1246    /// not get to clear it. Caller holds the trail lock.
1247    ///
1248    /// The transaction on-chain ⇒ the intent's trail becomes the stored one
1249    /// (unless a later advance already did); definitely not on-chain ⇒ the
1250    /// intent is dropped; ambiguous ⇒ the advance is refused (fail closed).
1251    async fn settle_intent(&self, name: &str) -> Result<(), ProvenanceError> {
1252        use crate::trail_store::{clear_gitmark_intent, load_gitmark_trail, save_gitmark_trail};
1253        let storage = self.storage()?;
1254        let err = |what: &str, e: PaymentError| ProvenanceError::Anchor(format!("{what}: {e}"));
1255        let Some(pending) = load_pending_mark(storage, name).await? else {
1256            return Ok(());
1257        };
1258        let intent = &pending.intent;
1259        let exists = self
1260            .lookup
1261            .transaction_exists(&intent.txid)
1262            .await
1263            .map_err(|e| {
1264                ProvenanceError::Anchor(format!(
1265                    "a previous mark ({}) is unresolved, refusing to advance: {e}",
1266                    intent.txid
1267                ))
1268            })?;
1269        // "Not found" is not "not broadcast": a backend that took the mark
1270        // but has not indexed it says the same. Send it again; the intent is
1271        // dropped only for a mark that predates `rawHex` and cannot be.
1272        let stored = load_gitmark_trail(storage, name)
1273            .await
1274            .map_err(|e| err("load trail", e))?;
1275        if let Some(raw_hex) = &pending.raw_hex {
1276            verify_intent_tx(
1277                &intent.txid,
1278                raw_hex,
1279                stored
1280                    .as_ref()
1281                    .and_then(|t| t.trail.txo.last())
1282                    .map(|m| (m.txid.as_str(), m.vout)),
1283                stored.as_ref().map_or(0, |t| t.trail.txo.len()) < intent.stored.trail.txo.len(),
1284            )?;
1285        }
1286        let landed = if exists {
1287            true
1288        } else if let Some(raw_hex) = &pending.raw_hex {
1289            let head = stored
1290                .as_ref()
1291                .and_then(|t| t.trail.txo.last())
1292                .map(|m| (m.txid.as_str(), m.vout));
1293            settle_broadcast(&self.lookup, &intent.txid, raw_hex, head).await?
1294        } else {
1295            tracing::warn!(trail = %name, txid = %intent.txid, "pending mark has no raw transaction; dropped as not on-chain");
1296            false
1297        };
1298        if landed && stored.map_or(0, |t| t.trail.txo.len()) < intent.stored.trail.txo.len() {
1299            save_gitmark_trail(storage, &intent.stored)
1300                .await
1301                .map_err(|e| err("save recovered trail", e))?;
1302        }
1303        clear_gitmark_intent(storage, name)
1304            .await
1305            .map_err(|e| err("clear pending mark", e))
1306    }
1307
1308    /// Write the intent, broadcast, save the trail, clear the intent. Caller
1309    /// holds the trail lock and has settled any earlier intent.
1310    ///
1311    /// Once the broadcast is accepted the mark is on-chain, so a failed save
1312    /// is not a failed anchor: the intent stays and the next advance settles
1313    /// it. The caller must not be refunded for it.
1314    async fn broadcast_and_save(
1315        &self,
1316        name: &str,
1317        privkey: String,
1318        update: GitmarkUpdate,
1319        network: &str,
1320        date_created: String,
1321    ) -> Result<BlockTrailAnchor, ProvenanceError> {
1322        use crate::trail_store::{
1323            clear_gitmark_intent, save_gitmark_trail, GitmarkIntent, StoredGitmarkTrail,
1324        };
1325        let storage = self.storage()?;
1326        let expected = solid_pod_rs::bitcoin_tx::decode_tx_info(&update.tx.raw_hex)
1327            .map_err(|e| ProvenanceError::Anchor(format!("decode mark: {e}")))?
1328            .txid;
1329        let mut trail = update.trail;
1330        if let Some(last) = trail.txo.last_mut() {
1331            last.txid = expected.clone();
1332        }
1333        let state_strings = trail
1334            .state_strings()
1335            .map_err(|e| ProvenanceError::Anchor(format!("trail states: {e}")))?;
1336        let pubkey = trail.pubkey_base.clone();
1337        let stored = StoredGitmarkTrail {
1338            name: name.to_string(),
1339            privkey,
1340            trail,
1341            date_created,
1342        };
1343        save_pending_mark(
1344            storage,
1345            &PendingMark {
1346                intent: GitmarkIntent {
1347                    txid: expected.clone(),
1348                    stored: stored.clone(),
1349                },
1350                raw_hex: Some(update.tx.raw_hex.clone()),
1351            },
1352        )
1353        .await
1354        .map_err(|e| ProvenanceError::Anchor(format!("record pending mark: {e}")))?;
1355
1356        let txid = self
1357            .lookup
1358            .broadcast_tx(&update.tx.raw_hex)
1359            .await
1360            .map_err(|e| pending_anchor(format!("broadcast mark: {e}")))?;
1361        if txid != expected {
1362            return Err(pending_anchor(format!(
1363                "mempool returned txid {txid} for a mark that is {expected}"
1364            )));
1365        }
1366        match save_gitmark_trail(storage, &stored).await {
1367            Ok(()) => {
1368                if let Err(e) = clear_gitmark_intent(storage, name).await {
1369                    // Settling it later is a no-op: the trail is already saved.
1370                    tracing::warn!(trail = %name, "git-mark intent not cleared: {e}");
1371                }
1372            }
1373            Err(e) => tracing::error!(
1374                trail = %name, txid = %txid,
1375                "git-mark broadcast accepted but trail save failed; the next advance settles it: {e}"
1376            ),
1377        }
1378        Ok(BlockTrailAnchor {
1379            ticker: name.to_string(),
1380            state_hash: update.txo.commit.clone().unwrap_or_default(),
1381            txid,
1382            vout: update.txo.vout,
1383            address: update.address,
1384            network: network.to_string(),
1385            blockheight: None,
1386            state_strings,
1387            pubkey,
1388        })
1389    }
1390
1391    /// Start the git-mark trail `name`: spend `voucher` to its first mark,
1392    /// committing to `commit` ([`gitmark_genesis`]), broadcast, and save the
1393    /// trail with the voucher key as its base secret.
1394    ///
1395    /// # Errors
1396    ///
1397    /// [`ProvenanceError::Anchor`] when there is no storage, the trail already
1398    /// exists (a trail is never restarted over one that has marks), or the
1399    /// build, broadcast or save fails.
1400    pub async fn genesis(
1401        &self,
1402        name: &str,
1403        voucher: &TxoVoucher,
1404        commit: &str,
1405        network: &str,
1406        date_created: &str,
1407    ) -> Result<BlockTrailAnchor, ProvenanceError> {
1408        use crate::trail_store::{gitmark_trail_path, load_gitmark_trail};
1409        let storage = self.storage()?;
1410        let _guard = crate::trail_store::lock_path(&gitmark_trail_path(name)).await;
1411        self.settle_intent(name).await?;
1412        if load_gitmark_trail(storage, name)
1413            .await
1414            .map_err(|e| ProvenanceError::Anchor(format!("load trail {name}: {e}")))?
1415            .is_some()
1416        {
1417            return Err(ProvenanceError::Anchor(format!(
1418                "git-mark trail {name} already exists"
1419            )));
1420        }
1421        let update = gitmark_genesis(voucher, commit, network, DEFAULT_FEE_SATS, &self.lookup)
1422            .await
1423            .map_err(|e| ProvenanceError::Anchor(format!("build genesis mark: {e}")))?;
1424        self.broadcast_and_save(
1425            name,
1426            voucher.privkey.clone(),
1427            update,
1428            network,
1429            date_created.to_string(),
1430        )
1431        .await
1432    }
1433
1434    /// The per-link report behind [`BlockAnchorer::verify`] (see
1435    /// [`verify_anchor_report`]).
1436    pub async fn verify_report(
1437        &self,
1438        anchor: &BlockTrailAnchor,
1439    ) -> Result<Option<TrailReport>, ProvenanceError> {
1440        verify_anchor_report(&self.lookup, anchor).await
1441    }
1442}
1443
1444#[async_trait(?Send)]
1445impl<M: MempoolLookup + MempoolBroadcast + MempoolTxPresence + Send + Sync> BlockAnchorer
1446    for GitmarkAnchorer<M>
1447{
1448    /// Advance the git-mark trail `ticker` by the commit `state_hash`: spend
1449    /// its newest mark to the next ([`gitmark_advance`]), broadcast, save, and
1450    /// return the new mark as a [`BlockTrailAnchor`] (`state_strings` are the
1451    /// commits so far, `pubkey` the trail's base key).
1452    ///
1453    /// `network` must be the trail's own (its chain token read as a network
1454    /// name, so `testnet4` for a `tbtc4` trail).
1455    async fn anchor(
1456        &self,
1457        ticker: &str,
1458        state_hash: &str,
1459        network: &str,
1460    ) -> Result<BlockTrailAnchor, ProvenanceError> {
1461        use crate::trail_store::{gitmark_trail_path, load_gitmark_trail};
1462        let _guard = crate::trail_store::lock_path(&gitmark_trail_path(ticker)).await;
1463        self.settle_intent(ticker).await?;
1464        let stored = load_gitmark_trail(self.storage()?, ticker)
1465            .await
1466            .map_err(|e| ProvenanceError::Anchor(format!("load trail {ticker}: {e}")))?
1467            .ok_or_else(|| {
1468                ProvenanceError::Anchor(format!(
1469                    "git-mark trail {ticker} has no genesis on this pod"
1470                ))
1471            })?;
1472        let trail_network = stored.network();
1473        if trail_network != network {
1474            return Err(ProvenanceError::Anchor(format!(
1475                "network mismatch: trail is {trail_network}, requested {network}"
1476            )));
1477        }
1478        let update = gitmark_advance(
1479            &stored.trail,
1480            &stored.privkey,
1481            state_hash,
1482            DEFAULT_FEE_SATS,
1483            &self.lookup,
1484        )
1485        .await
1486        .map_err(|e| ProvenanceError::Anchor(format!("build mark: {e}")))?;
1487        self.broadcast_and_save(ticker, stored.privkey, update, network, stored.date_created)
1488            .await
1489    }
1490
1491    async fn verify(&self, anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
1492        Ok(verify_anchor_report(&self.lookup, anchor)
1493            .await?
1494            .is_some_and(|r| r.is_intact()))
1495    }
1496}
1497
1498// ---------------------------------------------------------------------------
1499// Tests — fixture parsing only (NO live mempool.space access).
1500// ---------------------------------------------------------------------------
1501
1502#[cfg(test)]
1503mod tests {
1504    use super::*;
1505
1506    /// A captured mempool.space `GET /api/address/{addr}/utxo` payload:
1507    /// one confirmed UTXO. Deserialises into the flat [`Utxo`].
1508    const UTXO_JSON: &str = include_str!("../tests/fixtures/mempool/address_utxos.json");
1509    /// A captured `GET /api/tx/{txid}` payload (one confirmed tx, 2 outputs).
1510    const TX_JSON: &str = include_str!("../tests/fixtures/mempool/tx.json");
1511    /// An empty UTXO set (`[]`) — the "no deposit yet" response.
1512    const EMPTY_UTXO_JSON: &str = "[]";
1513
1514    #[test]
1515    fn utxo_wire_flattens_status() {
1516        let wire: Vec<UtxoWire> = serde_json::from_str(UTXO_JSON).unwrap();
1517        let utxos: Vec<Utxo> = wire.into_iter().map(Utxo::from).collect();
1518        assert_eq!(utxos.len(), 1);
1519        assert_eq!(utxos[0].vout, 0);
1520        assert_eq!(utxos[0].value, 9700);
1521        assert!(
1522            utxos[0].confirmed,
1523            "status.confirmed must flatten onto Utxo"
1524        );
1525        assert_eq!(utxos[0].block_height, Some(42_000));
1526    }
1527
1528    #[test]
1529    fn empty_utxo_set_parses_to_empty_vec() {
1530        let wire: Vec<UtxoWire> = serde_json::from_str(EMPTY_UTXO_JSON).unwrap();
1531        assert!(wire.is_empty());
1532    }
1533
1534    #[test]
1535    fn tx_wire_flattens_outputs_and_status() {
1536        let wire: TxWire = serde_json::from_str(TX_JSON).unwrap();
1537        let tx = TxInfo::from(wire);
1538        assert_eq!(tx.vout.len(), 2);
1539        assert_eq!(tx.vout[0].value, 9700);
1540        assert_eq!(
1541            tx.vout[0].scriptpubkey.as_deref(),
1542            Some("5120aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899")
1543        );
1544        assert_eq!(
1545            tx.vout[0].scriptpubkey_address.as_deref(),
1546            Some("tb1pexampleaddress")
1547        );
1548        assert!(tx.confirmed);
1549        assert_eq!(tx.block_height, Some(42_000));
1550    }
1551
1552    #[test]
1553    fn from_env_defaults_to_testnet4() {
1554        // Snapshot/restore so a parallel test or the host env can't perturb it.
1555        let prev = std::env::var(MEMPOOL_URL_ENV).ok();
1556        std::env::remove_var(MEMPOOL_URL_ENV);
1557        let c = MempoolHttpClient::from_env();
1558        assert_eq!(c.base_url(), DEFAULT_MEMPOOL_URL);
1559        if let Some(v) = prev {
1560            std::env::set_var(MEMPOOL_URL_ENV, v);
1561        }
1562    }
1563
1564    #[test]
1565    fn new_trims_trailing_slash() {
1566        let c = MempoolHttpClient::new("https://mempool.space/testnet4/");
1567        assert_eq!(c.base_url(), "https://mempool.space/testnet4");
1568    }
1569
1570    /// Live smoke test — disabled by default (no live chain in CI). Run with
1571    /// `cargo test -p solid-pod-rs-server --features git -- --ignored live_`.
1572    #[ignore = "hits live mempool.space; opt-in only"]
1573    #[tokio::test]
1574    async fn live_address_utxos_smoke() {
1575        let c = MempoolHttpClient::from_env();
1576        // A well-known testnet4 faucet-ish address may have UTXOs; the test
1577        // only asserts the call shape succeeds (empty is acceptable).
1578        let _ = c
1579            .address_utxos("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c")
1580            .await;
1581    }
1582
1583    // ── BlockAnchorer::verify over a FIXTURE MempoolLookup (no network) ──
1584
1585    use std::collections::HashMap;
1586
1587    /// In-memory [`MempoolLookup`] + [`MempoolBroadcast`]: address→UTXO and
1588    /// txid→transaction maps, no HTTP. A broadcast is decoded and kept under
1589    /// its real txid (confirmed), so the per-link walk can follow every mark
1590    /// back to the one it spends. Interior mutability so clones share state.
1591    #[derive(Clone, Default)]
1592    struct FixtureMempool {
1593        utxos: std::sync::Arc<std::sync::Mutex<HashMap<String, Vec<Utxo>>>>,
1594        txs: std::sync::Arc<std::sync::Mutex<HashMap<String, TxInfo>>>,
1595        broadcasts: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1596    }
1597    impl FixtureMempool {
1598        fn empty() -> Self {
1599            Self::default()
1600        }
1601        /// Register an output by hand (a funding voucher).
1602        fn add_output(&self, txid: &str, vout: u32, spk_hex: &str) {
1603            let mut txs = self.txs.lock().unwrap();
1604            let tx = txs.entry(txid.to_string()).or_insert_with(|| TxInfo {
1605                txid: txid.to_string(),
1606                vin: vec![],
1607                vout: vec![],
1608                confirmed: true,
1609                block_height: Some(41_000),
1610            });
1611            while tx.vout.len() <= vout as usize {
1612                tx.vout.push(TxOut {
1613                    value: 0,
1614                    scriptpubkey: None,
1615                    scriptpubkey_address: None,
1616                });
1617            }
1618            tx.vout[vout as usize].scriptpubkey = Some(spk_hex.to_string());
1619        }
1620        /// A whole trail on-chain for `state_strings`: one transaction per
1621        /// state, each paying the key its prefix derives and spending the one
1622        /// before. Returns the head txid.
1623        fn add_chain(&self, pubkey: &str, state_strings: &[String]) -> String {
1624            let outputs = solid_pod_rs::mrc20::bt_trail_outputs(pubkey, state_strings).unwrap();
1625            let mut prev = "ff".repeat(32);
1626            for (i, x) in outputs.iter().enumerate() {
1627                let txid = solid_pod_rs::mrc20::sha256_hex(&format!("{pubkey} mark {i}"));
1628                self.txs.lock().unwrap().insert(
1629                    txid.clone(),
1630                    TxInfo {
1631                        txid: txid.clone(),
1632                        vin: vec![TxIn {
1633                            txid: prev,
1634                            vout: 0,
1635                        }],
1636                        vout: vec![TxOut {
1637                            value: 9_700,
1638                            scriptpubkey: Some(format!("5120{}", hex::encode(x))),
1639                            scriptpubkey_address: None,
1640                        }],
1641                        confirmed: true,
1642                        block_height: Some(42_000 + i as u64),
1643                    },
1644                );
1645                prev = txid;
1646            }
1647            prev
1648        }
1649    }
1650    #[async_trait(?Send)]
1651    impl MempoolLookup for FixtureMempool {
1652        async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
1653            Ok(self
1654                .utxos
1655                .lock()
1656                .unwrap()
1657                .get(address)
1658                .cloned()
1659                .unwrap_or_default())
1660        }
1661        async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
1662            self.txs
1663                .lock()
1664                .unwrap()
1665                .get(txid)
1666                .cloned()
1667                .ok_or_else(|| PaymentError::InvalidState(format!("tx {txid} not found")))
1668        }
1669    }
1670    #[async_trait(?Send)]
1671    impl MempoolBroadcast for FixtureMempool {
1672        async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
1673            let mut info = solid_pod_rs::bitcoin_tx::decode_tx_info(raw_hex)?;
1674            info.confirmed = true;
1675            info.block_height = Some(43_000);
1676            let txid = info.txid.clone();
1677            self.txs.lock().unwrap().insert(txid.clone(), info);
1678            self.broadcasts.lock().unwrap().push(raw_hex.to_string());
1679            Ok(txid)
1680        }
1681    }
1682
1683    #[async_trait(?Send)]
1684    impl MempoolTxPresence for FixtureMempool {
1685        async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError> {
1686            Ok(self.txs.lock().unwrap().contains_key(txid))
1687        }
1688    }
1689
1690    // Issuer keypair (arbitrary) for deriving real anchor addresses.
1691    const ISSUER_PRIVKEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
1692    fn issuer_pubkey() -> String {
1693        let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
1694        hex::encode(sk.public_key().to_sec1_bytes())
1695    }
1696
1697    /// Build a `BlockTrailAnchor` whose `address`/`state_strings`/`pubkey`
1698    /// are internally consistent (the `address` is the genuine derivation),
1699    /// with its head at `txid`.
1700    fn consistent_anchor(txid: &str) -> BlockTrailAnchor {
1701        let pubkey = issuer_pubkey();
1702        let state_strings = vec!["{\"seq\":0}".to_string(), "{\"seq\":1}".to_string()];
1703        let address = bt_address(&pubkey, &state_strings, "testnet4").unwrap();
1704        BlockTrailAnchor {
1705            ticker: "PROV".into(),
1706            state_hash: "ff".repeat(32),
1707            txid: txid.to_string(),
1708            vout: 0,
1709            address,
1710            network: "testnet4".into(),
1711            blockheight: Some(42_000),
1712            state_strings,
1713            pubkey: Some(pubkey),
1714        }
1715    }
1716
1717    /// A fixture holding the whole trail of [`consistent_anchor`], and the
1718    /// anchor at its head.
1719    fn anchored_chain() -> (FixtureMempool, BlockTrailAnchor) {
1720        let mempool = FixtureMempool::empty();
1721        let probe = consistent_anchor("");
1722        let head = mempool.add_chain(&issuer_pubkey(), &probe.state_strings);
1723        (mempool, consistent_anchor(&head))
1724    }
1725
1726    #[test]
1727    fn tx_wire_reads_inputs() {
1728        let wire: TxWire = serde_json::from_str(
1729            r#"{"txid":"aa","vin":[{"txid":"bb","vout":3,"prevout":{}},{"is_coinbase":true}],"vout":[],"status":{"confirmed":false}}"#,
1730        )
1731        .unwrap();
1732        let tx = TxInfo::from(wire);
1733        assert_eq!(tx.vin.len(), 2);
1734        assert_eq!((tx.vin[0].txid.as_str(), tx.vin[0].vout), ("bb", 3));
1735        assert!(!tx.confirmed);
1736    }
1737
1738    #[tokio::test]
1739    async fn block_anchorer_verify_true_when_every_link_is_on_chain() {
1740        let (mempool, anchor) = anchored_chain();
1741        let anchorer = MempoolBlockAnchorer::new(mempool);
1742        assert!(anchorer.verify(&anchor).await.unwrap());
1743        let report = anchorer.verify_report(&anchor).await.unwrap().unwrap();
1744        assert!(report.is_verified());
1745        assert_eq!(report.marks.len(), 2);
1746    }
1747
1748    #[tokio::test]
1749    async fn block_anchorer_verify_false_when_the_anchor_tx_is_absent() {
1750        let anchor = consistent_anchor(&"ab".repeat(32));
1751        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
1752        assert!(
1753            !anchorer.verify(&anchor).await.unwrap(),
1754            "no transaction behind the anchor ⇒ verify false"
1755        );
1756    }
1757
1758    #[tokio::test]
1759    async fn block_anchorer_verify_false_when_an_earlier_mark_does_not_commit() {
1760        // The head is the right key, but the genesis mark behind it is not:
1761        // the head-only check accepted this; the walk does not.
1762        let (mempool, anchor) = anchored_chain();
1763        let genesis = solid_pod_rs::mrc20::sha256_hex(&format!("{} mark 0", issuer_pubkey()));
1764        mempool.txs.lock().unwrap().get_mut(&genesis).unwrap().vout[0].scriptpubkey =
1765            Some(format!("5120{}", "22".repeat(32)));
1766        let anchorer = MempoolBlockAnchorer::new(mempool);
1767        assert!(!anchorer.verify(&anchor).await.unwrap());
1768        let report = anchorer.verify_report(&anchor).await.unwrap().unwrap();
1769        assert_eq!(
1770            report.marks[0].status,
1771            solid_pod_rs::blocktrail::MarkStatus::WrongKey
1772        );
1773    }
1774
1775    #[tokio::test]
1776    async fn block_anchorer_verify_false_when_address_forged() {
1777        // The chain is real, but the anchor *claims* a different (forged)
1778        // address → the re-derivation mismatch fails it.
1779        let (mempool, mut anchor) = anchored_chain();
1780        anchor.address = "tb1pforged000000000000000000000000000000".into();
1781        let anchorer = MempoolBlockAnchorer::new(mempool);
1782        assert!(
1783            !anchorer.verify(&anchor).await.unwrap(),
1784            "forged address must not verify even with a real chain behind it"
1785        );
1786        assert!(anchorer.verify_report(&anchor).await.unwrap().is_none());
1787    }
1788
1789    #[tokio::test]
1790    async fn block_anchorer_verify_false_without_pubkey() {
1791        // No pubkey ⇒ nothing to re-derive against ⇒ not verifiable.
1792        let (mempool, mut anchor) = anchored_chain();
1793        anchor.pubkey = None;
1794        let anchorer = MempoolBlockAnchorer::new(mempool);
1795        assert!(!anchorer.verify(&anchor).await.unwrap());
1796    }
1797
1798    #[tokio::test]
1799    async fn block_anchorer_anchor_requires_storage() {
1800        // The verify-only constructor leaves storage None ⇒ anchor() errors
1801        // with a clear message rather than panicking.
1802        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
1803        let err = anchorer
1804            .anchor("PROV", "deadbeef", "testnet4")
1805            .await
1806            .unwrap_err();
1807        match err {
1808            ProvenanceError::Anchor(m) => assert!(m.contains("with_storage")),
1809            other => panic!("expected Anchor(requires storage), got {other:?}"),
1810        }
1811    }
1812
1813    // ── Phase 4: full anchor() round-trip (mint → store → anchor → verify) ──
1814
1815    use crate::trail_store::{load_gitmark_trail, load_trail, save_trail, StoredTrail};
1816    use solid_pod_rs::bitcoin_tx::mint_token;
1817    use solid_pod_rs::storage::memory::MemoryBackend;
1818    use solid_pod_rs::storage::Storage;
1819
1820    /// The issuer key's own (untweaked) output: a voucher it can spend.
1821    fn issuer_voucher(mempool: &FixtureMempool, txid: &str) -> TxoVoucher {
1822        let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
1823        let compressed = sk.public_key().to_sec1_bytes();
1824        mempool.add_output(txid, 0, &format!("5120{}", hex::encode(&compressed[1..])));
1825        TxoVoucher {
1826            txid: txid.to_string(),
1827            vout: 0,
1828            amount: 100_000,
1829            privkey: ISSUER_PRIVKEY.to_string(),
1830        }
1831    }
1832
1833    /// Mint a genesis trail through the write-side, broadcast it, and persist
1834    /// it (with the issuer secret). Returns `(storage, mempool, ticker)`.
1835    async fn mint_and_store(ticker: &str) -> (std::sync::Arc<dyn Storage>, FixtureMempool, String) {
1836        let mempool = FixtureMempool::empty();
1837        let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
1838        let voucher = issuer_voucher(&mempool, &"11".repeat(32));
1839        let mint = mint_token(ticker, None, 1_000, &voucher, "testnet4", 300, &mempool)
1840            .await
1841            .unwrap();
1842        let mint_txid = mempool.broadcast_tx(&mint.tx.raw_hex).await.unwrap();
1843
1844        // Persist the trail with the issuer secret + the broadcast txid.
1845        let stored = StoredTrail {
1846            ticker: mint.trail.ticker.clone(),
1847            name: mint.trail.name.clone(),
1848            supply: mint.trail.supply,
1849            privkey: ISSUER_PRIVKEY.to_string(),
1850            pubkey_base: mint.trail.pubkey_base.clone(),
1851            states: mint.trail.states.clone(),
1852            state_strings: mint.trail.state_strings.clone(),
1853            current_txid: mint_txid,
1854            current_vout: 0,
1855            current_amount: mint.trail.current_amount,
1856            network: mint.trail.network.clone(),
1857            date_created: "2026-06-13T00:00:00Z".into(),
1858        };
1859        save_trail(&storage, &stored).await.unwrap();
1860        (storage, mempool, ticker.to_string())
1861    }
1862
1863    #[tokio::test]
1864    async fn block_anchorer_anchor_round_trip_and_self_verifies() {
1865        let (storage, mempool, ticker) = mint_and_store("ANCH").await;
1866        let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), storage.clone());
1867
1868        // Anchor a git commit SHA (the provenance write).
1869        let commit_sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9001122334";
1870        let anchor = anchorer
1871            .anchor(&ticker, commit_sha, "testnet4")
1872            .await
1873            .expect("anchor() must build + broadcast + persist");
1874
1875        assert_eq!(anchor.ticker, "ANCH");
1876        assert_eq!(anchor.state_hash, commit_sha);
1877        assert_eq!(anchor.vout, 0);
1878        assert!(anchor.blockheight.is_none());
1879        assert_eq!(anchor.network, "testnet4");
1880        assert!(anchor.pubkey.is_some());
1881        // The portable proof carries genesis + anchor state strings.
1882        assert_eq!(anchor.state_strings.len(), 2);
1883        // The recorded address is the genuine derivation from the proof.
1884        let derived = bt_address(
1885            anchor.pubkey.as_deref().unwrap(),
1886            &anchor.state_strings,
1887            "testnet4",
1888        )
1889        .unwrap();
1890        assert_eq!(anchor.address, derived);
1891
1892        // The trail was persisted with the new state appended + new txid; the
1893        // issued trail keeps its MRC20 anchor state.
1894        let reloaded = load_trail(&storage, "ANCH").await.unwrap().unwrap();
1895        assert_eq!(reloaded.states.len(), 2);
1896        assert_eq!(reloaded.current_txid, anchor.txid);
1897        assert_eq!(reloaded.states[1].anchor.as_deref(), Some(commit_sha));
1898        assert_eq!(reloaded.states[1].ops[0].op, "urn:mono:op:anchor");
1899
1900        // verify() walks the produced anchor back to the genesis mark.
1901        assert!(
1902            anchorer.verify(&anchor).await.unwrap(),
1903            "the anchor we just produced must verify, every link"
1904        );
1905
1906        // A second anchor spends the first; the first still verifies (the
1907        // head-only check needed it to be unspent).
1908        let second = anchorer
1909            .anchor(&ticker, &"b".repeat(40), "testnet4")
1910            .await
1911            .unwrap();
1912        assert_eq!(second.state_strings.len(), 3);
1913        assert!(anchorer.verify(&second).await.unwrap());
1914        assert!(anchorer.verify(&anchor).await.unwrap());
1915    }
1916
1917    #[tokio::test]
1918    async fn block_anchorer_anchor_rejects_unminted_ticker() {
1919        let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
1920        let anchorer = MempoolBlockAnchorer::with_storage(FixtureMempool::empty(), storage);
1921        let err = anchorer
1922            .anchor("GHOST", "deadbeef", "testnet4")
1923            .await
1924            .unwrap_err();
1925        match err {
1926            ProvenanceError::Anchor(m) => assert!(m.contains("not minted")),
1927            other => panic!("expected not-minted error, got {other:?}"),
1928        }
1929    }
1930
1931    // ── GitmarkAnchorer: new trails, the commit as the state ──
1932
1933    const COMMITS: [&str; 3] = [
1934        "0123456789abcdef0123456789abcdef01234567",
1935        "cf97baba489e88c1ffbe6758c0fe8c18ff83d17d",
1936        "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
1937    ];
1938
1939    #[tokio::test]
1940    async fn gitmark_anchorer_genesis_anchor_verify() {
1941        let mempool = FixtureMempool::empty();
1942        let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
1943        let anchorer = GitmarkAnchorer::with_storage(mempool.clone(), storage.clone());
1944        let voucher = issuer_voucher(&mempool, &"33".repeat(32));
1945
1946        let genesis = anchorer
1947            .genesis(
1948                "prov",
1949                &voucher,
1950                COMMITS[0],
1951                "testnet4",
1952                "2026-10-02T00:00:00Z",
1953            )
1954            .await
1955            .unwrap();
1956        assert_eq!(genesis.state_strings, vec![COMMITS[0].to_string()]);
1957        assert_eq!(genesis.pubkey.as_deref(), Some(issuer_pubkey().as_str()));
1958        assert!(anchorer.verify(&genesis).await.unwrap());
1959        // never restarted over a trail that has marks
1960        let again = anchorer
1961            .genesis("prov", &voucher, COMMITS[0], "testnet4", "")
1962            .await
1963            .unwrap_err();
1964        assert!(again.to_string().contains("already exists"));
1965
1966        let mut last = genesis.clone();
1967        for commit in &COMMITS[1..] {
1968            last = anchorer.anchor("prov", commit, "testnet4").await.unwrap();
1969            assert_eq!(last.state_hash, *commit);
1970        }
1971        assert_eq!(
1972            last.state_strings,
1973            COMMITS.iter().map(|c| c.to_string()).collect::<Vec<_>>()
1974        );
1975        let report = anchorer.verify_report(&last).await.unwrap().unwrap();
1976        assert!(report.is_verified(), "{report:?}");
1977        assert_eq!(report.marks.len(), 3);
1978        // older marks stay verifiable after later ones spend them
1979        assert!(anchorer.verify(&genesis).await.unwrap());
1980
1981        // the stored trail is the §5.2 shape, and its published copy verifies
1982        // with blocktrails/verify's walk
1983        let stored = load_gitmark_trail(&storage, "prov").await.unwrap().unwrap();
1984        assert_eq!(stored.trail.txo.len(), 3);
1985        assert_eq!(stored.trail.txo[2].txid, last.txid);
1986        let report = solid_pod_rs::blocktrail::verify_blocktrail(&stored.trail, &mempool).await;
1987        assert!(report.is_verified(), "{report:?}");
1988        let (published, _) = storage
1989            .get(&crate::trail_store::gitmark_blocktrails_path("prov"))
1990            .await
1991            .unwrap();
1992        let published: solid_pod_rs::blocktrail::Blocktrail =
1993            serde_json::from_slice(&published).unwrap();
1994        assert_eq!(published, stored.trail);
1995    }
1996
1997    #[tokio::test]
1998    async fn gitmark_anchorer_refusals() {
1999        let mempool = FixtureMempool::empty();
2000        let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
2001        let anchorer = GitmarkAnchorer::with_storage(mempool.clone(), storage);
2002        let err = anchorer
2003            .anchor("prov", COMMITS[0], "testnet4")
2004            .await
2005            .unwrap_err();
2006        assert!(err.to_string().contains("no genesis"), "{err}");
2007
2008        let voucher = issuer_voucher(&mempool, &"34".repeat(32));
2009        anchorer
2010            .genesis("prov", &voucher, COMMITS[0], "testnet4", "")
2011            .await
2012            .unwrap();
2013        let err = anchorer
2014            .anchor("prov", COMMITS[1], "mainnet")
2015            .await
2016            .unwrap_err();
2017        assert!(err.to_string().contains("network mismatch"), "{err}");
2018        let err = anchorer
2019            .anchor("prov", "not-a-commit", "testnet4")
2020            .await
2021            .unwrap_err();
2022        assert!(err.to_string().contains("commit hash"), "{err}");
2023
2024        let verify_only = GitmarkAnchorer::new(mempool);
2025        let err = verify_only
2026            .anchor("prov", COMMITS[1], "testnet4")
2027            .await
2028            .unwrap_err();
2029        assert!(err.to_string().contains("with_storage"), "{err}");
2030    }
2031
2032    // ── GitmarkAnchorer: serialised advances, durable broadcast intent ──
2033
2034    /// A mempool that yields on every call (so concurrent tasks interleave),
2035    /// rejects a double spend like a node would, and can drop a broadcast.
2036    #[derive(Clone)]
2037    struct RacyMempool {
2038        inner: FixtureMempool,
2039        spent: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<(String, u32)>>>,
2040        drop_next_broadcast: std::sync::Arc<std::sync::atomic::AtomicBool>,
2041        /// The backend accepted a broadcast but has not indexed it yet:
2042        /// presence checks answer "not found".
2043        index_lag: std::sync::Arc<std::sync::atomic::AtomicBool>,
2044        /// The next broadcast is refused with this node message.
2045        reject_next: std::sync::Arc<std::sync::Mutex<Option<String>>>,
2046    }
2047    impl RacyMempool {
2048        fn new(inner: FixtureMempool) -> Self {
2049            Self {
2050                inner,
2051                spent: Default::default(),
2052                drop_next_broadcast: Default::default(),
2053                index_lag: Default::default(),
2054                reject_next: Default::default(),
2055            }
2056        }
2057        fn reject_next(&self, msg: &str) {
2058            *self.reject_next.lock().unwrap() = Some(msg.into());
2059        }
2060        /// Show `txid:vout` as an unspent output of a known address, as an
2061        /// esplora backend does.
2062        fn show_unspent(&self, txid: &str, vout: u32) {
2063            let address = format!("tb1q{txid}{vout}");
2064            self.inner.txs.lock().unwrap().get_mut(txid).unwrap().vout[vout as usize]
2065                .scriptpubkey_address = Some(address.clone());
2066            self.inner.utxos.lock().unwrap().insert(
2067                address,
2068                vec![Utxo {
2069                    txid: txid.into(),
2070                    vout,
2071                    value: 9_700,
2072                    confirmed: true,
2073                    block_height: Some(1),
2074                }],
2075            );
2076        }
2077    }
2078    #[async_trait(?Send)]
2079    impl MempoolLookup for RacyMempool {
2080        async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
2081            tokio::task::yield_now().await;
2082            self.inner.address_utxos(address).await
2083        }
2084        async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
2085            tokio::task::yield_now().await;
2086            self.inner.tx(txid).await
2087        }
2088    }
2089    #[async_trait(?Send)]
2090    impl MempoolBroadcast for RacyMempool {
2091        async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
2092            tokio::task::yield_now().await;
2093            if self
2094                .drop_next_broadcast
2095                .swap(false, std::sync::atomic::Ordering::SeqCst)
2096            {
2097                return Err(PaymentError::InvalidState("connection reset".into()));
2098            }
2099            let info = solid_pod_rs::bitcoin_tx::decode_tx_info(raw_hex)?;
2100            if let Some(msg) = self.reject_next.lock().unwrap().take() {
2101                return Err(PaymentError::InvalidState(msg));
2102            }
2103            // A node refuses the identical transaction a second time.
2104            if self.inner.txs.lock().unwrap().contains_key(&info.txid) {
2105                return Err(PaymentError::InvalidState(
2106                    "broadcast rejected (400): txn-already-in-mempool".into(),
2107                ));
2108            }
2109            {
2110                let mut spent = self.spent.lock().unwrap();
2111                if info
2112                    .vin
2113                    .iter()
2114                    .any(|i| spent.contains(&(i.txid.clone(), i.vout)))
2115                {
2116                    return Err(PaymentError::InvalidState("txn-mempool-conflict".into()));
2117                }
2118                for i in &info.vin {
2119                    spent.insert((i.txid.clone(), i.vout));
2120                }
2121            }
2122            self.inner.broadcast_tx(raw_hex).await
2123        }
2124    }
2125
2126    #[async_trait(?Send)]
2127    impl MempoolTxPresence for RacyMempool {
2128        async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError> {
2129            tokio::task::yield_now().await;
2130            if self.index_lag.load(std::sync::atomic::Ordering::SeqCst) {
2131                return Ok(false);
2132            }
2133            self.inner.transaction_exists(txid).await
2134        }
2135    }
2136
2137    /// Two separate anchorer instances (as `build_anchorer` makes per request)
2138    /// over one pod, with genesis already on-chain.
2139    async fn racy_trail() -> (
2140        RacyMempool,
2141        std::sync::Arc<crate::trail_store::fault_storage::FaultStorage>,
2142        GitmarkAnchorer<RacyMempool>,
2143        GitmarkAnchorer<RacyMempool>,
2144    ) {
2145        let fixture = FixtureMempool::empty();
2146        let voucher = issuer_voucher(&fixture, &"35".repeat(32));
2147        let mempool = RacyMempool::new(fixture);
2148        let inner: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
2149        let storage = crate::trail_store::fault_storage::FaultStorage::new(inner, true);
2150        let a = GitmarkAnchorer::with_storage(mempool.clone(), storage.clone());
2151        let b = GitmarkAnchorer::with_storage(mempool.clone(), storage.clone());
2152        a.genesis("prov", &voucher, COMMITS[0], "testnet4", "")
2153            .await
2154            .unwrap();
2155        (mempool, storage, a, b)
2156    }
2157
2158    #[tokio::test]
2159    async fn concurrent_anchors_build_on_each_others_head() {
2160        let (mempool, storage, a, b) = racy_trail().await;
2161        let (ra, rb) = tokio::join!(
2162            a.anchor("prov", COMMITS[1], "testnet4"),
2163            b.anchor("prov", COMMITS[2], "testnet4"),
2164        );
2165        let (ra, rb) = (ra.unwrap(), rb.unwrap());
2166        let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2167        let stored = load_gitmark_trail(&dyn_storage, "prov")
2168            .await
2169            .unwrap()
2170            .unwrap();
2171        assert_eq!(stored.trail.txo.len(), 3, "both anchors must land");
2172        let heads = [ra.txid.clone(), rb.txid.clone()];
2173        assert_eq!(
2174            heads
2175                .iter()
2176                .filter(|t| **t == stored.trail.txo[2].txid)
2177                .count(),
2178            1
2179        );
2180        assert!(heads.contains(&stored.trail.txo[1].txid));
2181        let report = solid_pod_rs::blocktrail::verify_blocktrail(&stored.trail, &mempool).await;
2182        assert!(report.is_verified(), "{report:?}");
2183    }
2184
2185    #[tokio::test]
2186    async fn broadcast_then_failed_save_is_ok_and_recovered_by_the_next_anchor() {
2187        let (mempool, storage, a, _b) = racy_trail().await;
2188        storage.fail_put(&crate::trail_store::gitmark_trail_path("prov"));
2189        let landed = a
2190            .anchor("prov", COMMITS[1], "testnet4")
2191            .await
2192            .expect("the tx is on-chain: a failed save must not read as a failed anchor");
2193        storage.heal();
2194        let next = a.anchor("prov", COMMITS[2], "testnet4").await.unwrap();
2195        let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2196        let stored = load_gitmark_trail(&dyn_storage, "prov")
2197            .await
2198            .unwrap()
2199            .unwrap();
2200        assert_eq!(stored.trail.txo.len(), 3);
2201        assert_eq!(stored.trail.txo[1].txid, landed.txid);
2202        assert_eq!(stored.trail.txo[2].txid, next.txid);
2203        let report = solid_pod_rs::blocktrail::verify_blocktrail(&stored.trail, &mempool).await;
2204        assert!(report.is_verified(), "{report:?}");
2205    }
2206
2207    #[test]
2208    fn already_known_reads_only_node_statements_about_the_same_tx() {
2209        let e = |m: &str| PaymentError::InvalidState(m.into());
2210        assert!(already_known(&e(
2211            "broadcast rejected (400): txn-already-in-mempool"
2212        )));
2213        assert!(already_known(&e(
2214            "broadcast rejected (400): Transaction already in block chain"
2215        )));
2216        assert!(already_known(&e(
2217            "broadcast rejected (400): txn-already-known"
2218        )));
2219        assert!(!already_known(&e(
2220            "broadcast rejected (400): txn-mempool-conflict"
2221        )));
2222        assert!(!already_known(&e(
2223            "mempool broadcast failed: connection reset"
2224        )));
2225    }
2226
2227    #[tokio::test]
2228    async fn dropped_broadcast_is_resent_by_the_next_anchor() {
2229        let (mempool, storage, a, _b) = racy_trail().await;
2230        mempool
2231            .drop_next_broadcast
2232            .store(true, std::sync::atomic::Ordering::SeqCst);
2233        let lost = a.anchor("prov", COMMITS[1], "testnet4").await;
2234        assert!(
2235            is_pending_anchor(&lost.unwrap_err()),
2236            "a recorded but unconfirmed broadcast is pending, not failed"
2237        );
2238        // the intent survives the failed call
2239        let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2240        assert!(
2241            crate::trail_store::load_gitmark_intent(&dyn_storage, "prov")
2242                .await
2243                .unwrap()
2244                .is_some()
2245        );
2246        // the next advance re-sends the same mark (it is not on-chain), then
2247        // builds on it
2248        let next = a.anchor("prov", COMMITS[2], "testnet4").await.unwrap();
2249        let stored = load_gitmark_trail(&dyn_storage, "prov")
2250            .await
2251            .unwrap()
2252            .unwrap();
2253        assert_eq!(stored.trail.txo.len(), 3, "the re-sent mark landed");
2254        assert_eq!(stored.trail.txo[2].txid, next.txid);
2255        assert!(
2256            crate::trail_store::load_gitmark_intent(&dyn_storage, "prov")
2257                .await
2258                .unwrap()
2259                .is_none()
2260        );
2261        let report = solid_pod_rs::blocktrail::verify_blocktrail(&stored.trail, &mempool).await;
2262        assert!(report.is_verified(), "{report:?}");
2263    }
2264
2265    #[tokio::test]
2266    async fn unresolved_resend_keeps_the_intent_and_refuses_to_advance() {
2267        let (mempool, storage, a, _b) = racy_trail().await;
2268        let drop_next = || {
2269            mempool
2270                .drop_next_broadcast
2271                .store(true, std::sync::atomic::Ordering::SeqCst)
2272        };
2273        drop_next();
2274        a.anchor("prov", COMMITS[1], "testnet4").await.unwrap_err();
2275        // the re-send fails too: nothing is known, so nothing is dropped
2276        drop_next();
2277        a.anchor("prov", COMMITS[2], "testnet4").await.unwrap_err();
2278        let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2279        assert!(
2280            crate::trail_store::load_gitmark_intent(&dyn_storage, "prov")
2281                .await
2282                .unwrap()
2283                .is_some(),
2284            "an unresolved mark must keep its intent"
2285        );
2286        let stored = load_gitmark_trail(&dyn_storage, "prov")
2287            .await
2288            .unwrap()
2289            .unwrap();
2290        assert_eq!(stored.trail.txo.len(), 1);
2291    }
2292
2293    #[tokio::test]
2294    async fn rejected_resend_with_an_unspent_head_drops_the_intent() {
2295        let (mempool, storage, a, _b) = racy_trail().await;
2296        let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2297        let head = load_gitmark_trail(&dyn_storage, "prov")
2298            .await
2299            .unwrap()
2300            .unwrap()
2301            .trail
2302            .txo[0]
2303            .txid
2304            .clone();
2305        mempool
2306            .drop_next_broadcast
2307            .store(true, std::sync::atomic::Ordering::SeqCst);
2308        a.anchor("prov", COMMITS[1], "testnet4").await.unwrap_err();
2309        // the node refuses the mark outright, and the head it spends is unspent
2310        mempool.reject_next("broadcast rejected (400): min relay fee not met");
2311        mempool.show_unspent(&head, 0);
2312        a.anchor("prov", COMMITS[2], "testnet4")
2313            .await
2314            .expect("a refused mark must not wedge the trail");
2315        let stored = load_gitmark_trail(&dyn_storage, "prov")
2316            .await
2317            .unwrap()
2318            .unwrap();
2319        assert_eq!(stored.trail.txo.len(), 2, "the refused mark never landed");
2320        assert!(
2321            crate::trail_store::load_gitmark_intent(&dyn_storage, "prov")
2322                .await
2323                .unwrap()
2324                .is_none()
2325        );
2326    }
2327
2328    #[tokio::test]
2329    async fn rejected_resend_without_proof_of_an_unspent_head_keeps_refusing() {
2330        let (mempool, storage, a, _b) = racy_trail().await;
2331        mempool
2332            .drop_next_broadcast
2333            .store(true, std::sync::atomic::Ordering::SeqCst);
2334        a.anchor("prov", COMMITS[1], "testnet4").await.unwrap_err();
2335        // refused, but nothing shows the head is still unspent
2336        mempool.reject_next("broadcast rejected (400): bad-txns-inputs-missingorspent");
2337        a.anchor("prov", COMMITS[2], "testnet4").await.unwrap_err();
2338        let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2339        assert!(
2340            crate::trail_store::load_gitmark_intent(&dyn_storage, "prov")
2341                .await
2342                .unwrap()
2343                .is_some()
2344        );
2345    }
2346
2347    #[tokio::test]
2348    async fn block_anchorer_rejected_resend_with_an_unspent_head_drops_the_intent() {
2349        let (inner, fixture, ticker) = mint_and_store("DRP").await;
2350        let mempool = RacyMempool::new(fixture);
2351        let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), inner.clone());
2352        let head = load_trail(&inner, &ticker)
2353            .await
2354            .unwrap()
2355            .unwrap()
2356            .current_txid;
2357        mempool
2358            .drop_next_broadcast
2359            .store(true, std::sync::atomic::Ordering::SeqCst);
2360        anchorer
2361            .anchor(&ticker, &"a".repeat(40), "testnet4")
2362            .await
2363            .unwrap_err();
2364        mempool.reject_next("broadcast rejected (400): min relay fee not met");
2365        mempool.show_unspent(&head, 0);
2366        anchorer
2367            .anchor(&ticker, &"b".repeat(40), "testnet4")
2368            .await
2369            .expect("a refused anchor must not wedge the trail");
2370        let stored = load_trail(&inner, &ticker).await.unwrap().unwrap();
2371        assert_eq!(stored.states.len(), 2);
2372        assert!(!inner.exists(&anchor_intent_path(&ticker)).await.unwrap());
2373    }
2374
2375    #[tokio::test]
2376    async fn block_anchorer_refuses_an_intent_whose_txid_is_not_its_transaction() {
2377        let (inner, fixture, ticker) = mint_and_store("TMP").await;
2378        let mempool = RacyMempool::new(fixture);
2379        let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), inner.clone());
2380        mempool
2381            .drop_next_broadcast
2382            .store(true, std::sync::atomic::Ordering::SeqCst);
2383        anchorer
2384            .anchor(&ticker, &"a".repeat(40), "testnet4")
2385            .await
2386            .unwrap_err();
2387        // Tamper with the recorded txid, as a writer of the intent file could,
2388        // naming a transaction the mempool does know: the stored head. Taken
2389        // at face value it reads as "landed", and the trail would be advanced
2390        // over an anchor that never went out.
2391        let path = anchor_intent_path(&ticker);
2392        let head = load_trail(&inner, &ticker)
2393            .await
2394            .unwrap()
2395            .unwrap()
2396            .current_txid;
2397        let (bytes, _) = inner.get(&path).await.unwrap();
2398        let mut v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2399        v["txid"] = serde_json::json!(head);
2400        inner
2401            .put(
2402                &path,
2403                bytes::Bytes::from(serde_json::to_vec(&v).unwrap()),
2404                "application/json",
2405            )
2406            .await
2407            .unwrap();
2408        let before = load_trail(&inner, &ticker).await.unwrap().unwrap();
2409        let err = anchorer
2410            .anchor(&ticker, &"b".repeat(40), "testnet4")
2411            .await
2412            .unwrap_err();
2413        assert!(err.to_string().contains("refused"), "{err}");
2414        let after = load_trail(&inner, &ticker).await.unwrap().unwrap();
2415        assert_eq!(after.states.len(), before.states.len());
2416        assert!(inner.exists(&path).await.unwrap(), "the intent is kept");
2417    }
2418
2419    #[tokio::test]
2420    async fn accepted_but_unindexed_mark_is_not_orphaned_by_the_next_anchor() {
2421        let (mempool, storage, a, _b) = racy_trail().await;
2422        // the broadcast is accepted, the trail save is lost
2423        storage.fail_put(&crate::trail_store::gitmark_trail_path("prov"));
2424        let landed = a.anchor("prov", COMMITS[1], "testnet4").await.unwrap();
2425        storage.heal();
2426        // the backend has not indexed it yet: presence answers 404
2427        mempool
2428            .index_lag
2429            .store(true, std::sync::atomic::Ordering::SeqCst);
2430        let next = a
2431            .anchor("prov", COMMITS[2], "testnet4")
2432            .await
2433            .expect("the next advance must build on the accepted mark, not on the old head");
2434        let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2435        let stored = load_gitmark_trail(&dyn_storage, "prov")
2436            .await
2437            .unwrap()
2438            .unwrap();
2439        assert_eq!(stored.trail.txo.len(), 3);
2440        assert_eq!(stored.trail.txo[1].txid, landed.txid);
2441        assert_eq!(stored.trail.txo[2].txid, next.txid);
2442        let report = solid_pod_rs::blocktrail::verify_blocktrail(&stored.trail, &mempool).await;
2443        assert!(report.is_verified(), "{report:?}");
2444    }
2445
2446    #[tokio::test]
2447    async fn block_anchorer_accepted_broadcast_with_failed_save_is_not_a_failed_anchor() {
2448        use crate::trail_store::fault_storage::FaultStorage;
2449        let (inner, mempool, ticker) = mint_and_store("FLT").await;
2450        let storage = FaultStorage::new(inner, false);
2451        let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), storage.clone());
2452        storage.fail_put(&crate::trail_store::trail_path(&ticker));
2453        let first = anchorer
2454            .anchor(&ticker, &"a".repeat(40), "testnet4")
2455            .await
2456            .expect("the tx is on-chain: a failed save must not read as a failed anchor");
2457        storage.heal();
2458        // the next advance settles the first, then builds on it
2459        let second = anchorer
2460            .anchor(&ticker, &"b".repeat(40), "testnet4")
2461            .await
2462            .unwrap();
2463        let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2464        let stored = load_trail(&dyn_storage, &ticker).await.unwrap().unwrap();
2465        assert_eq!(stored.states.len(), 3);
2466        assert_eq!(stored.current_txid, second.txid);
2467        let second_tx = mempool
2468            .txs
2469            .lock()
2470            .unwrap()
2471            .get(&second.txid)
2472            .cloned()
2473            .unwrap();
2474        assert_eq!(
2475            second_tx.vin[0].txid, first.txid,
2476            "the second anchor must spend the first, not the old head"
2477        );
2478    }
2479
2480    #[tokio::test]
2481    async fn http_client_presence_check_reaches_the_inherent_method() {
2482        // A closed port: the call must come back as an ambiguous Err (not a
2483        // definite `false`, not a recursion into the trait impl).
2484        let client = MempoolHttpClient::new("http://127.0.0.1:1");
2485        let out =
2486            <MempoolHttpClient as MempoolTxPresence>::transaction_exists(&client, &"ab".repeat(32))
2487                .await;
2488        assert!(out.is_err(), "{out:?}");
2489    }
2490}