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`] 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`]/[`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`] the
23//! `/pay/.deposit` MRC20 path and `/pay/.address` derivation use. wasm
24//! consumers implement [`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
35use async_trait::async_trait;
36use serde::Deserialize;
37
38use solid_pod_rs::bitcoin_tx::{anchor_state, MempoolBroadcast};
39use solid_pod_rs::mrc20::{bt_address, MempoolLookup, TxInfo, TxOut, Utxo};
40use solid_pod_rs::payments::PaymentError;
41use solid_pod_rs::provenance::{BlockAnchorer, BlockTrailAnchor, ProvenanceError};
42
43/// Environment variable selecting the mempool REST base URL (JSS parity).
44pub const MEMPOOL_URL_ENV: &str = "JSS_PAY_MEMPOOL_URL";
45
46/// Default base URL — the mempool.space **testnet4** explorer. Matches the
47/// JSS default (`pay.js:243`, `mrc20.js:282`).
48pub const DEFAULT_MEMPOOL_URL: &str = "https://mempool.space/testnet4";
49
50/// A [`MempoolLookup`] backed by the mempool.space REST API over `reqwest`.
51///
52/// Cheap to clone (holds an `Arc`-internal `reqwest::Client` and the base
53/// URL). Construct with [`MempoolHttpClient::from_env`] to honour
54/// `JSS_PAY_MEMPOOL_URL`, or [`MempoolHttpClient::new`] for an explicit base.
55#[derive(Debug, Clone)]
56pub struct MempoolHttpClient {
57    client: reqwest::Client,
58    /// Base URL with any trailing slash trimmed (so `{base}/api/...` joins
59    /// cleanly regardless of how the operator wrote the env value).
60    base: String,
61}
62
63impl MempoolHttpClient {
64    /// Construct a client against an explicit base URL (e.g.
65    /// `https://mempool.space/testnet4`). A trailing `/` is trimmed.
66    #[must_use]
67    pub fn new(base_url: impl Into<String>) -> Self {
68        let base = base_url.into().trim_end_matches('/').to_string();
69        Self {
70            client: reqwest::Client::new(),
71            base,
72        }
73    }
74
75    /// Construct from `JSS_PAY_MEMPOOL_URL`, falling back to
76    /// [`DEFAULT_MEMPOOL_URL`] (testnet4).
77    #[must_use]
78    pub fn from_env() -> Self {
79        let base = std::env::var(MEMPOOL_URL_ENV)
80            .ok()
81            .filter(|v| !v.trim().is_empty())
82            .unwrap_or_else(|| DEFAULT_MEMPOOL_URL.to_string());
83        Self::new(base)
84    }
85
86    /// The configured base URL (trailing slash trimmed).
87    #[must_use]
88    pub fn base_url(&self) -> &str {
89        &self.base
90    }
91
92    /// GET `url`, returning the body text on a 2xx, or a fail-closed
93    /// [`PaymentError::InvalidState`] describing the transport/status error.
94    async fn get_text(&self, url: &str) -> Result<String, PaymentError> {
95        let resp = self
96            .client
97            .get(url)
98            .send()
99            .await
100            .map_err(|e| PaymentError::InvalidState(format!("mempool request failed: {e}")))?;
101        let status = resp.status();
102        if !status.is_success() {
103            return Err(PaymentError::InvalidState(format!(
104                "mempool API error: {} for {url}",
105                status.as_u16()
106            )));
107        }
108        resp.text()
109            .await
110            .map_err(|e| PaymentError::InvalidState(format!("mempool body read failed: {e}")))
111    }
112
113    /// POST `body` as `text/plain` to `url`, returning the response body on a
114    /// 2xx (the txid, for `/api/tx`) or a fail-closed
115    /// [`PaymentError::InvalidState`]. Mirrors JSS `broadcastTx`
116    /// (`token.js:176-187`).
117    async fn post_text(&self, url: &str, body: &str) -> Result<String, PaymentError> {
118        let resp = self
119            .client
120            .post(url)
121            .header("Content-Type", "text/plain")
122            .body(body.to_string())
123            .send()
124            .await
125            .map_err(|e| PaymentError::InvalidState(format!("mempool broadcast failed: {e}")))?;
126        let status = resp.status();
127        let text = resp
128            .text()
129            .await
130            .map_err(|e| PaymentError::InvalidState(format!("mempool body read failed: {e}")))?;
131        if !status.is_success() {
132            return Err(PaymentError::InvalidState(format!(
133                "broadcast rejected ({}): {text}",
134                status.as_u16()
135            )));
136        }
137        Ok(text.trim().to_string())
138    }
139}
140
141// ── Wire shapes (mempool.space schema) ──────────────────────────────────
142
143/// Nested `status` object on UTXO/tx responses.
144#[derive(Debug, Deserialize, Default)]
145struct StatusWire {
146    #[serde(default)]
147    confirmed: bool,
148    #[serde(default)]
149    block_height: Option<u64>,
150}
151
152/// One element of `GET /api/address/{addr}/utxo`.
153#[derive(Debug, Deserialize)]
154struct UtxoWire {
155    txid: String,
156    vout: u32,
157    #[serde(default)]
158    value: u64,
159    #[serde(default)]
160    status: StatusWire,
161}
162
163impl From<UtxoWire> for Utxo {
164    fn from(w: UtxoWire) -> Self {
165        Utxo {
166            txid: w.txid,
167            vout: w.vout,
168            value: w.value,
169            confirmed: w.status.confirmed,
170            block_height: w.status.block_height,
171        }
172    }
173}
174
175/// One element of a tx's `vout` array.
176#[derive(Debug, Deserialize, Default)]
177struct TxOutWire {
178    #[serde(default)]
179    value: u64,
180    #[serde(default)]
181    scriptpubkey: Option<String>,
182    #[serde(default)]
183    scriptpubkey_address: Option<String>,
184}
185
186impl From<TxOutWire> for TxOut {
187    fn from(w: TxOutWire) -> Self {
188        TxOut {
189            value: w.value,
190            scriptpubkey: w.scriptpubkey,
191            scriptpubkey_address: w.scriptpubkey_address,
192        }
193    }
194}
195
196/// Shape of `GET /api/tx/{txid}`.
197#[derive(Debug, Deserialize)]
198struct TxWire {
199    txid: String,
200    #[serde(default)]
201    vout: Vec<TxOutWire>,
202    #[serde(default)]
203    status: StatusWire,
204}
205
206impl From<TxWire> for TxInfo {
207    fn from(w: TxWire) -> Self {
208        TxInfo {
209            txid: w.txid,
210            vout: w.vout.into_iter().map(TxOut::from).collect(),
211            confirmed: w.status.confirmed,
212            block_height: w.status.block_height,
213        }
214    }
215}
216
217#[async_trait(?Send)]
218impl MempoolLookup for MempoolHttpClient {
219    async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
220        let url = format!("{}/api/address/{address}/utxo", self.base);
221        let body = self.get_text(&url).await?;
222        let wire: Vec<UtxoWire> = serde_json::from_str(&body)
223            .map_err(|e| PaymentError::InvalidState(format!("malformed utxo JSON: {e}")))?;
224        Ok(wire.into_iter().map(Utxo::from).collect())
225    }
226
227    async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
228        let url = format!("{}/api/tx/{txid}", self.base);
229        let body = self.get_text(&url).await?;
230        let wire: TxWire = serde_json::from_str(&body)
231            .map_err(|e| PaymentError::InvalidState(format!("malformed tx JSON: {e}")))?;
232        Ok(TxInfo::from(wire))
233    }
234}
235
236#[async_trait(?Send)]
237impl MempoolBroadcast for MempoolHttpClient {
238    async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
239        let url = format!("{}/api/tx", self.base);
240        self.post_text(&url, raw_hex).await
241    }
242}
243
244// ---------------------------------------------------------------------------
245// BlockAnchorer::verify — the portable-proof read-side (provenance §2.2)
246// ---------------------------------------------------------------------------
247
248/// A [`BlockAnchorer`] implementing **both** sides over a transport that can
249/// look up UTXOs ([`MempoolLookup`]) and broadcast transactions
250/// ([`MempoolBroadcast`]). Generic over that transport so a fixture drives it
251/// in tests and [`MempoolHttpClient`] drives it in production — without
252/// changing the logic.
253///
254/// - `verify` (Phase 3) re-derives the expected taproot address from the
255///   anchor's *portable proof* (`pubkey` + `state_strings`) via [`bt_address`],
256///   rejects a forged `address`, and confirms a UTXO sits at the derived
257///   address. No pod trust required.
258/// - `anchor` (Phase 4) loads the named trail from storage, appends an MRC20
259///   state notarising `state_hash` (via
260///   [`anchor_state`](solid_pod_rs::bitcoin_tx::anchor_state)), broadcasts the
261///   anchoring tx, persists the updated trail, and returns the
262///   [`BlockTrailAnchor`] (txid/vout/address/state_strings/pubkey). It requires
263///   a `storage` handle (set via [`MempoolBlockAnchorer::with_storage`]); the
264///   verify-only constructor [`MempoolBlockAnchorer::new`] leaves it `None` and
265///   `anchor()` then errors with a clear message.
266#[derive(Clone)]
267pub struct MempoolBlockAnchorer<M: MempoolLookup + MempoolBroadcast + Send + Sync> {
268    lookup: M,
269    storage: Option<std::sync::Arc<dyn solid_pod_rs::storage::Storage>>,
270}
271
272impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> MempoolBlockAnchorer<M> {
273    /// Wrap a transport as a **verify-capable** [`BlockAnchorer`]. `anchor()`
274    /// is unavailable (no storage) and returns an error explaining that
275    /// [`with_storage`](Self::with_storage) is required.
276    pub fn new(lookup: M) -> Self {
277        Self { lookup, storage: None }
278    }
279
280    /// Wrap a transport + pod storage as a **fully-capable** [`BlockAnchorer`]
281    /// (both `verify` and `anchor`). The `storage` backs the trail load/save at
282    /// `/.well-known/token/{ticker}.json`.
283    pub fn with_storage(
284        lookup: M,
285        storage: std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
286    ) -> Self {
287        Self {
288            lookup,
289            storage: Some(storage),
290        }
291    }
292
293    /// Borrow the underlying transport (e.g. for a one-off `address_utxos`).
294    pub fn lookup(&self) -> &M {
295        &self.lookup
296    }
297}
298
299#[async_trait(?Send)]
300impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> BlockAnchorer for MempoolBlockAnchorer<M> {
301    /// Append one MRC20 state anchoring `state_hash` under `ticker`, build +
302    /// broadcast the anchoring tx, persist the updated trail, and return the
303    /// produced [`BlockTrailAnchor`]. This is the expensive-tier write the
304    /// provenance design hinges on (ADR-059 §2.2, master-plan Phase 4).
305    ///
306    /// `network` is honoured as a guard: it must match the trail's own network
307    /// (the trail's chained-key addresses are network-bound). The returned
308    /// anchor's `vout` is `0` (the anchoring tx pays the next chained-key UTXO
309    /// at output 0); `blockheight` is `None` until the tx confirms.
310    async fn anchor(
311        &self,
312        ticker: &str,
313        state_hash: &str,
314        network: &str,
315    ) -> Result<BlockTrailAnchor, ProvenanceError> {
316        use crate::trail_store::{load_trail, save_trail};
317        use solid_pod_rs::bitcoin_tx::DEFAULT_FEE_SATS;
318
319        let storage = self.storage.as_ref().ok_or_else(|| {
320            ProvenanceError::Anchor(
321                "anchor() requires storage; construct with MempoolBlockAnchorer::with_storage"
322                    .into(),
323            )
324        })?;
325
326        // Load the trail that will carry the anchor (JSS `loadTrail`).
327        let mut stored = load_trail(storage, ticker)
328            .await
329            .map_err(|e| ProvenanceError::Anchor(format!("load trail {ticker}: {e}")))?
330            .ok_or_else(|| ProvenanceError::Anchor(format!("trail {ticker} not minted on this pod")))?;
331
332        if stored.network != network {
333            return Err(ProvenanceError::Anchor(format!(
334                "network mismatch: trail is {}, requested {network}",
335                stored.network
336            )));
337        }
338
339        // Build the anchoring tx (appends a state notarising `state_hash`).
340        let public = stored.to_public();
341        let update = anchor_state(&public, &stored.privkey, state_hash, DEFAULT_FEE_SATS, &self.lookup)
342            .await
343            .map_err(|e| ProvenanceError::Anchor(format!("build anchoring tx: {e}")))?;
344
345        // Broadcast (JSS `broadcastTx`). The returned txid IS the anchoring tx.
346        let txid = self
347            .lookup
348            .broadcast_tx(&update.tx.raw_hex)
349            .await
350            .map_err(|e| ProvenanceError::Anchor(format!("broadcast anchoring tx: {e}")))?;
351
352        // Persist the appended trail with the broadcast txid as the new
353        // currentTxid (so the next anchor/transfer spends this output).
354        let mut appended = update.trail.clone();
355        appended.current_txid = txid.clone();
356        stored.merge_public(&appended);
357        stored.current_txid = txid.clone();
358        stored.current_vout = 0;
359        save_trail(storage, &stored)
360            .await
361            .map_err(|e| ProvenanceError::Anchor(format!("save trail: {e}")))?;
362
363        Ok(BlockTrailAnchor {
364            ticker: ticker.to_string(),
365            state_hash: state_hash.to_string(),
366            txid,
367            vout: 0,
368            address: update.address,
369            network: network.to_string(),
370            blockheight: None,
371            state_strings: appended.state_strings,
372            pubkey: Some(stored.pubkey_base),
373        })
374    }
375
376    async fn verify(&self, anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
377        // The portable proof requires both the issuer pubkey and the state
378        // strings. Absent either, there is nothing to independently
379        // re-derive against → not verifiable (false, not error).
380        let Some(pubkey) = anchor.pubkey.as_deref() else {
381            return Ok(false);
382        };
383        if anchor.state_strings.is_empty() {
384            return Ok(false);
385        }
386
387        // Re-derive the taproot address from the proof and reject a forged
388        // `address` field (the recorded address must equal the derivation).
389        let derived = bt_address(pubkey, &anchor.state_strings, &anchor.network)
390            .map_err(|e| ProvenanceError::Anchor(format!("address re-derivation failed: {e}")))?;
391        if derived != anchor.address {
392            return Ok(false);
393        }
394
395        // A genuine anchor has a live UTXO at the derived address.
396        let utxos = self
397            .lookup
398            .address_utxos(&derived)
399            .await
400            .map_err(|e| ProvenanceError::Anchor(format!("mempool lookup failed: {e}")))?;
401        Ok(!utxos.is_empty())
402    }
403}
404
405// ---------------------------------------------------------------------------
406// Tests — fixture parsing only (NO live mempool.space access).
407// ---------------------------------------------------------------------------
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    /// A captured mempool.space `GET /api/address/{addr}/utxo` payload:
414    /// one confirmed UTXO. Deserialises into the flat [`Utxo`].
415    const UTXO_JSON: &str = include_str!("../tests/fixtures/mempool/address_utxos.json");
416    /// A captured `GET /api/tx/{txid}` payload (one confirmed tx, 2 outputs).
417    const TX_JSON: &str = include_str!("../tests/fixtures/mempool/tx.json");
418    /// An empty UTXO set (`[]`) — the "no deposit yet" response.
419    const EMPTY_UTXO_JSON: &str = "[]";
420
421    #[test]
422    fn utxo_wire_flattens_status() {
423        let wire: Vec<UtxoWire> = serde_json::from_str(UTXO_JSON).unwrap();
424        let utxos: Vec<Utxo> = wire.into_iter().map(Utxo::from).collect();
425        assert_eq!(utxos.len(), 1);
426        assert_eq!(utxos[0].vout, 0);
427        assert_eq!(utxos[0].value, 9700);
428        assert!(utxos[0].confirmed, "status.confirmed must flatten onto Utxo");
429        assert_eq!(utxos[0].block_height, Some(42_000));
430    }
431
432    #[test]
433    fn empty_utxo_set_parses_to_empty_vec() {
434        let wire: Vec<UtxoWire> = serde_json::from_str(EMPTY_UTXO_JSON).unwrap();
435        assert!(wire.is_empty());
436    }
437
438    #[test]
439    fn tx_wire_flattens_outputs_and_status() {
440        let wire: TxWire = serde_json::from_str(TX_JSON).unwrap();
441        let tx = TxInfo::from(wire);
442        assert_eq!(tx.vout.len(), 2);
443        assert_eq!(tx.vout[0].value, 9700);
444        assert_eq!(
445            tx.vout[0].scriptpubkey.as_deref(),
446            Some("5120aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899")
447        );
448        assert_eq!(
449            tx.vout[0].scriptpubkey_address.as_deref(),
450            Some("tb1pexampleaddress")
451        );
452        assert!(tx.confirmed);
453        assert_eq!(tx.block_height, Some(42_000));
454    }
455
456    #[test]
457    fn from_env_defaults_to_testnet4() {
458        // Snapshot/restore so a parallel test or the host env can't perturb it.
459        let prev = std::env::var(MEMPOOL_URL_ENV).ok();
460        std::env::remove_var(MEMPOOL_URL_ENV);
461        let c = MempoolHttpClient::from_env();
462        assert_eq!(c.base_url(), DEFAULT_MEMPOOL_URL);
463        if let Some(v) = prev {
464            std::env::set_var(MEMPOOL_URL_ENV, v);
465        }
466    }
467
468    #[test]
469    fn new_trims_trailing_slash() {
470        let c = MempoolHttpClient::new("https://mempool.space/testnet4/");
471        assert_eq!(c.base_url(), "https://mempool.space/testnet4");
472    }
473
474    /// Live smoke test — disabled by default (no live chain in CI). Run with
475    /// `cargo test -p solid-pod-rs-server --features git -- --ignored live_`.
476    #[ignore = "hits live mempool.space; opt-in only"]
477    #[tokio::test]
478    async fn live_address_utxos_smoke() {
479        let c = MempoolHttpClient::from_env();
480        // A well-known testnet4 faucet-ish address may have UTXOs; the test
481        // only asserts the call shape succeeds (empty is acceptable).
482        let _ = c
483            .address_utxos("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c")
484            .await;
485    }
486
487    // ── BlockAnchorer::verify over a FIXTURE MempoolLookup (no network) ──
488
489    use std::collections::HashMap;
490
491    /// In-memory [`MempoolLookup`] + [`MempoolBroadcast`] — address→UTXO and
492    /// txid→outputs maps. No HTTP. Interior mutability so the broadcast side
493    /// can record raw txs and the anchor round-trip can register the spent
494    /// output's scriptPubKey.
495    #[derive(Clone, Default)]
496    struct FixtureMempool {
497        utxos: std::sync::Arc<std::sync::Mutex<HashMap<String, Vec<Utxo>>>>,
498        txs: std::sync::Arc<std::sync::Mutex<HashMap<String, Vec<TxOut>>>>,
499        broadcasts: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
500    }
501    impl FixtureMempool {
502        fn with_utxo_at(address: &str) -> Self {
503            let me = Self::default();
504            me.utxos.lock().unwrap().insert(
505                address.to_string(),
506                vec![Utxo {
507                    txid: "ab".repeat(32),
508                    vout: 0,
509                    value: 9700,
510                    confirmed: true,
511                    block_height: Some(42_000),
512                }],
513            );
514            me
515        }
516        fn empty() -> Self {
517            Self::default()
518        }
519        fn add_output(&self, txid: &str, vout: u32, spk_hex: &str) {
520            let mut txs = self.txs.lock().unwrap();
521            let outs = txs.entry(txid.to_string()).or_default();
522            while outs.len() <= vout as usize {
523                outs.push(TxOut { value: 0, scriptpubkey: None, scriptpubkey_address: None });
524            }
525            outs[vout as usize] = TxOut {
526                value: 0,
527                scriptpubkey: Some(spk_hex.to_string()),
528                scriptpubkey_address: None,
529            };
530        }
531    }
532    #[async_trait(?Send)]
533    impl MempoolLookup for FixtureMempool {
534        async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
535            Ok(self.utxos.lock().unwrap().get(address).cloned().unwrap_or_default())
536        }
537        async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
538            Ok(TxInfo {
539                txid: txid.to_string(),
540                vout: self.txs.lock().unwrap().get(txid).cloned().unwrap_or_default(),
541                confirmed: true,
542                block_height: Some(42_000),
543            })
544        }
545    }
546    #[async_trait(?Send)]
547    impl MempoolBroadcast for FixtureMempool {
548        async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
549            // Synthetic, stable txid (sha256 of raw hex) — crypto correctness
550            // is asserted elsewhere; the chain-walk only needs uniqueness.
551            let txid = solid_pod_rs::mrc20::sha256_hex(raw_hex);
552            self.broadcasts.lock().unwrap().push(raw_hex.to_string());
553            Ok(txid)
554        }
555    }
556
557    // Issuer keypair (arbitrary) for deriving real anchor addresses.
558    const ISSUER_PRIVKEY: &str =
559        "0000000000000000000000000000000000000000000000000000000000000001";
560    fn issuer_pubkey() -> String {
561        let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
562        hex::encode(sk.public_key().to_sec1_bytes())
563    }
564
565    /// Build a `BlockTrailAnchor` whose `address`/`state_strings`/`pubkey`
566    /// are internally consistent (the `address` is the genuine derivation).
567    fn consistent_anchor() -> BlockTrailAnchor {
568        let pubkey = issuer_pubkey();
569        let state_strings = vec!["{\"seq\":0}".to_string(), "{\"seq\":1}".to_string()];
570        let address = bt_address(&pubkey, &state_strings, "testnet4").unwrap();
571        BlockTrailAnchor {
572            ticker: "PROV".into(),
573            state_hash: "ff".repeat(32),
574            txid: "ab".repeat(32),
575            vout: 0,
576            address,
577            network: "testnet4".into(),
578            blockheight: Some(42_000),
579            state_strings,
580            pubkey: Some(pubkey),
581        }
582    }
583
584    #[tokio::test]
585    async fn block_anchorer_verify_true_when_utxo_present() {
586        let anchor = consistent_anchor();
587        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&anchor.address));
588        assert!(anchorer.verify(&anchor).await.unwrap(), "present UTXO ⇒ verify true");
589    }
590
591    #[tokio::test]
592    async fn block_anchorer_verify_false_when_utxo_absent() {
593        let anchor = consistent_anchor();
594        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
595        assert!(!anchorer.verify(&anchor).await.unwrap(), "absent UTXO ⇒ verify false");
596    }
597
598    #[tokio::test]
599    async fn block_anchorer_verify_false_when_address_forged() {
600        // A UTXO sits at the real derived address, but the anchor *claims* a
601        // different (forged) address → the re-derivation mismatch fails it.
602        let mut anchor = consistent_anchor();
603        let real = anchor.address.clone();
604        anchor.address = "tb1pforged000000000000000000000000000000".into();
605        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&real));
606        assert!(
607            !anchorer.verify(&anchor).await.unwrap(),
608            "forged address must not verify even with a real UTXO elsewhere"
609        );
610    }
611
612    #[tokio::test]
613    async fn block_anchorer_verify_false_without_pubkey() {
614        // No pubkey ⇒ nothing to re-derive against ⇒ not verifiable.
615        let mut anchor = consistent_anchor();
616        anchor.pubkey = None;
617        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&anchor.address));
618        assert!(!anchorer.verify(&anchor).await.unwrap());
619    }
620
621    #[tokio::test]
622    async fn block_anchorer_anchor_requires_storage() {
623        // The verify-only constructor leaves storage None ⇒ anchor() errors
624        // with a clear message rather than panicking.
625        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
626        let err = anchorer.anchor("PROV", "deadbeef", "testnet4").await.unwrap_err();
627        match err {
628            ProvenanceError::Anchor(m) => assert!(m.contains("with_storage")),
629            other => panic!("expected Anchor(requires storage), got {other:?}"),
630        }
631    }
632
633    // ── Phase 4: full anchor() round-trip (mint → store → anchor → verify) ──
634
635    use crate::trail_store::{load_trail, save_trail, StoredTrail};
636    use solid_pod_rs::bitcoin_tx::mint_token;
637    use solid_pod_rs::storage::memory::MemoryBackend;
638    use solid_pod_rs::storage::Storage;
639
640    /// Mint a genesis trail through the write-side, persist it (with the
641    /// issuer secret), and register the genesis UTXO's scriptPubKey so a
642    /// subsequent anchor can spend it. Returns `(storage, mempool, ticker)`.
643    async fn mint_and_store(
644        ticker: &str,
645    ) -> (std::sync::Arc<dyn Storage>, FixtureMempool, String) {
646        let mempool = FixtureMempool::empty();
647        let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
648
649        // Fund the genesis from an issuer-key voucher (untweaked path).
650        let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
651        let compressed = sk.public_key().to_sec1_bytes();
652        let xonly_hex = hex::encode(&compressed[1..]);
653        let voucher_txid = "11".repeat(32);
654        mempool.add_output(&voucher_txid, 0, &format!("5120{xonly_hex}"));
655
656        let voucher = solid_pod_rs::bitcoin_tx::TxoVoucher {
657            txid: voucher_txid,
658            vout: 0,
659            amount: 100_000,
660            privkey: ISSUER_PRIVKEY.to_string(),
661        };
662        let mint = mint_token(ticker, None, 1_000, &voucher, "testnet4", 300, &mempool)
663            .await
664            .unwrap();
665        let mint_txid = mempool.broadcast_tx(&mint.tx.raw_hex).await.unwrap();
666
667        // Persist the trail with the issuer secret + the broadcast txid.
668        let mut stored = StoredTrail {
669            ticker: mint.trail.ticker.clone(),
670            name: mint.trail.name.clone(),
671            supply: mint.trail.supply,
672            privkey: ISSUER_PRIVKEY.to_string(),
673            pubkey_base: mint.trail.pubkey_base.clone(),
674            states: mint.trail.states.clone(),
675            state_strings: mint.trail.state_strings.clone(),
676            current_txid: mint_txid.clone(),
677            current_vout: 0,
678            current_amount: mint.trail.current_amount,
679            network: mint.trail.network.clone(),
680            date_created: "2026-06-13T00:00:00Z".into(),
681        };
682        stored.current_txid = mint_txid.clone();
683        save_trail(&storage, &stored).await.unwrap();
684
685        // Register the genesis output scriptPubKey so anchor() can spend it.
686        let genesis_xonly = {
687            let chained = solid_pod_rs::mrc20::bt_derive_chained_pubkey(
688                &issuer_pubkey(),
689                &[mint.state_jcs.clone()],
690            )
691            .unwrap();
692            hex::encode(&chained[1..])
693        };
694        mempool.add_output(&mint_txid, 0, &format!("5120{genesis_xonly}"));
695
696        (storage, mempool, ticker.to_string())
697    }
698
699    #[tokio::test]
700    async fn block_anchorer_anchor_round_trip_and_self_verifies() {
701        let (storage, mempool, ticker) = mint_and_store("ANCH").await;
702        let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), storage.clone());
703
704        // Anchor a git commit SHA (the provenance write).
705        let commit_sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9001122334";
706        let anchor = anchorer
707            .anchor(&ticker, commit_sha, "testnet4")
708            .await
709            .expect("anchor() must build + broadcast + persist");
710
711        assert_eq!(anchor.ticker, "ANCH");
712        assert_eq!(anchor.state_hash, commit_sha);
713        assert_eq!(anchor.vout, 0);
714        assert!(anchor.blockheight.is_none());
715        assert_eq!(anchor.network, "testnet4");
716        assert!(anchor.pubkey.is_some());
717        // The portable proof carries genesis + anchor state strings.
718        assert_eq!(anchor.state_strings.len(), 2);
719        // The recorded address is the genuine derivation from the proof.
720        let derived = bt_address(
721            anchor.pubkey.as_deref().unwrap(),
722            &anchor.state_strings,
723            "testnet4",
724        )
725        .unwrap();
726        assert_eq!(anchor.address, derived);
727
728        // The trail was persisted with the new state appended + new txid.
729        let reloaded = load_trail(&storage, "ANCH").await.unwrap().unwrap();
730        assert_eq!(reloaded.states.len(), 2);
731        assert_eq!(reloaded.current_txid, anchor.txid);
732        assert_eq!(reloaded.states[1].anchor.as_deref(), Some(commit_sha));
733
734        // verify() ACCEPTS the produced anchor once a UTXO sits at its address.
735        mempool.utxos.lock().unwrap().insert(
736            anchor.address.clone(),
737            vec![Utxo {
738                txid: anchor.txid.clone(),
739                vout: 0,
740                value: 9_400,
741                confirmed: false,
742                block_height: None,
743            }],
744        );
745        assert!(
746            anchorer.verify(&anchor).await.unwrap(),
747            "the anchor we just produced must verify against its own UTXO"
748        );
749    }
750
751    #[tokio::test]
752    async fn block_anchorer_anchor_rejects_unminted_ticker() {
753        let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
754        let anchorer = MempoolBlockAnchorer::with_storage(FixtureMempool::empty(), storage);
755        let err = anchorer.anchor("GHOST", "deadbeef", "testnet4").await.unwrap_err();
756        match err {
757            ProvenanceError::Anchor(m) => assert!(m.contains("not minted")),
758            other => panic!("expected not-minted error, got {other:?}"),
759        }
760    }
761}