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 {
278            lookup,
279            storage: None,
280        }
281    }
282
283    /// Wrap a transport + pod storage as a **fully-capable** [`BlockAnchorer`]
284    /// (both `verify` and `anchor`). The `storage` backs the trail load/save at
285    /// `/.well-known/token/{ticker}.json`.
286    pub fn with_storage(
287        lookup: M,
288        storage: std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
289    ) -> Self {
290        Self {
291            lookup,
292            storage: Some(storage),
293        }
294    }
295
296    /// Borrow the underlying transport (e.g. for a one-off `address_utxos`).
297    pub fn lookup(&self) -> &M {
298        &self.lookup
299    }
300}
301
302#[async_trait(?Send)]
303impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> BlockAnchorer for MempoolBlockAnchorer<M> {
304    /// Append one MRC20 state anchoring `state_hash` under `ticker`, build +
305    /// broadcast the anchoring tx, persist the updated trail, and return the
306    /// produced [`BlockTrailAnchor`]. This is the expensive-tier write the
307    /// provenance design hinges on (ADR-059 §2.2, master-plan Phase 4).
308    ///
309    /// `network` is honoured as a guard: it must match the trail's own network
310    /// (the trail's chained-key addresses are network-bound). The returned
311    /// anchor's `vout` is `0` (the anchoring tx pays the next chained-key UTXO
312    /// at output 0); `blockheight` is `None` until the tx confirms.
313    async fn anchor(
314        &self,
315        ticker: &str,
316        state_hash: &str,
317        network: &str,
318    ) -> Result<BlockTrailAnchor, ProvenanceError> {
319        use crate::trail_store::{load_trail, save_trail};
320        use solid_pod_rs::bitcoin_tx::DEFAULT_FEE_SATS;
321
322        let storage = self.storage.as_ref().ok_or_else(|| {
323            ProvenanceError::Anchor(
324                "anchor() requires storage; construct with MempoolBlockAnchorer::with_storage"
325                    .into(),
326            )
327        })?;
328
329        // Load the trail that will carry the anchor (JSS `loadTrail`).
330        let mut stored = load_trail(storage, ticker)
331            .await
332            .map_err(|e| ProvenanceError::Anchor(format!("load trail {ticker}: {e}")))?
333            .ok_or_else(|| {
334                ProvenanceError::Anchor(format!("trail {ticker} not minted on this pod"))
335            })?;
336
337        if stored.network != network {
338            return Err(ProvenanceError::Anchor(format!(
339                "network mismatch: trail is {}, requested {network}",
340                stored.network
341            )));
342        }
343
344        // Build the anchoring tx (appends a state notarising `state_hash`).
345        let public = stored.to_public();
346        let update = anchor_state(
347            &public,
348            &stored.privkey,
349            state_hash,
350            DEFAULT_FEE_SATS,
351            &self.lookup,
352        )
353        .await
354        .map_err(|e| ProvenanceError::Anchor(format!("build anchoring tx: {e}")))?;
355
356        // Broadcast (JSS `broadcastTx`). The returned txid IS the anchoring tx.
357        let txid = self
358            .lookup
359            .broadcast_tx(&update.tx.raw_hex)
360            .await
361            .map_err(|e| ProvenanceError::Anchor(format!("broadcast anchoring tx: {e}")))?;
362
363        // Persist the appended trail with the broadcast txid as the new
364        // currentTxid (so the next anchor/transfer spends this output).
365        let mut appended = update.trail.clone();
366        appended.current_txid = txid.clone();
367        stored.merge_public(&appended);
368        stored.current_txid = txid.clone();
369        stored.current_vout = 0;
370        save_trail(storage, &stored)
371            .await
372            .map_err(|e| ProvenanceError::Anchor(format!("save trail: {e}")))?;
373
374        Ok(BlockTrailAnchor {
375            ticker: ticker.to_string(),
376            state_hash: state_hash.to_string(),
377            txid,
378            vout: 0,
379            address: update.address,
380            network: network.to_string(),
381            blockheight: None,
382            state_strings: appended.state_strings,
383            pubkey: Some(stored.pubkey_base),
384        })
385    }
386
387    async fn verify(&self, anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
388        // The portable proof requires both the issuer pubkey and the state
389        // strings. Absent either, there is nothing to independently
390        // re-derive against → not verifiable (false, not error).
391        let Some(pubkey) = anchor.pubkey.as_deref() else {
392            return Ok(false);
393        };
394        if anchor.state_strings.is_empty() {
395            return Ok(false);
396        }
397
398        // Re-derive the taproot address from the proof and reject a forged
399        // `address` field (the recorded address must equal the derivation).
400        let derived = bt_address(pubkey, &anchor.state_strings, &anchor.network)
401            .map_err(|e| ProvenanceError::Anchor(format!("address re-derivation failed: {e}")))?;
402        if derived != anchor.address {
403            return Ok(false);
404        }
405
406        // A genuine anchor has a live UTXO at the derived address.
407        let utxos = self
408            .lookup
409            .address_utxos(&derived)
410            .await
411            .map_err(|e| ProvenanceError::Anchor(format!("mempool lookup failed: {e}")))?;
412        Ok(!utxos.is_empty())
413    }
414}
415
416// ---------------------------------------------------------------------------
417// Tests — fixture parsing only (NO live mempool.space access).
418// ---------------------------------------------------------------------------
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    /// A captured mempool.space `GET /api/address/{addr}/utxo` payload:
425    /// one confirmed UTXO. Deserialises into the flat [`Utxo`].
426    const UTXO_JSON: &str = include_str!("../tests/fixtures/mempool/address_utxos.json");
427    /// A captured `GET /api/tx/{txid}` payload (one confirmed tx, 2 outputs).
428    const TX_JSON: &str = include_str!("../tests/fixtures/mempool/tx.json");
429    /// An empty UTXO set (`[]`) — the "no deposit yet" response.
430    const EMPTY_UTXO_JSON: &str = "[]";
431
432    #[test]
433    fn utxo_wire_flattens_status() {
434        let wire: Vec<UtxoWire> = serde_json::from_str(UTXO_JSON).unwrap();
435        let utxos: Vec<Utxo> = wire.into_iter().map(Utxo::from).collect();
436        assert_eq!(utxos.len(), 1);
437        assert_eq!(utxos[0].vout, 0);
438        assert_eq!(utxos[0].value, 9700);
439        assert!(
440            utxos[0].confirmed,
441            "status.confirmed must flatten onto Utxo"
442        );
443        assert_eq!(utxos[0].block_height, Some(42_000));
444    }
445
446    #[test]
447    fn empty_utxo_set_parses_to_empty_vec() {
448        let wire: Vec<UtxoWire> = serde_json::from_str(EMPTY_UTXO_JSON).unwrap();
449        assert!(wire.is_empty());
450    }
451
452    #[test]
453    fn tx_wire_flattens_outputs_and_status() {
454        let wire: TxWire = serde_json::from_str(TX_JSON).unwrap();
455        let tx = TxInfo::from(wire);
456        assert_eq!(tx.vout.len(), 2);
457        assert_eq!(tx.vout[0].value, 9700);
458        assert_eq!(
459            tx.vout[0].scriptpubkey.as_deref(),
460            Some("5120aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899")
461        );
462        assert_eq!(
463            tx.vout[0].scriptpubkey_address.as_deref(),
464            Some("tb1pexampleaddress")
465        );
466        assert!(tx.confirmed);
467        assert_eq!(tx.block_height, Some(42_000));
468    }
469
470    #[test]
471    fn from_env_defaults_to_testnet4() {
472        // Snapshot/restore so a parallel test or the host env can't perturb it.
473        let prev = std::env::var(MEMPOOL_URL_ENV).ok();
474        std::env::remove_var(MEMPOOL_URL_ENV);
475        let c = MempoolHttpClient::from_env();
476        assert_eq!(c.base_url(), DEFAULT_MEMPOOL_URL);
477        if let Some(v) = prev {
478            std::env::set_var(MEMPOOL_URL_ENV, v);
479        }
480    }
481
482    #[test]
483    fn new_trims_trailing_slash() {
484        let c = MempoolHttpClient::new("https://mempool.space/testnet4/");
485        assert_eq!(c.base_url(), "https://mempool.space/testnet4");
486    }
487
488    /// Live smoke test — disabled by default (no live chain in CI). Run with
489    /// `cargo test -p solid-pod-rs-server --features git -- --ignored live_`.
490    #[ignore = "hits live mempool.space; opt-in only"]
491    #[tokio::test]
492    async fn live_address_utxos_smoke() {
493        let c = MempoolHttpClient::from_env();
494        // A well-known testnet4 faucet-ish address may have UTXOs; the test
495        // only asserts the call shape succeeds (empty is acceptable).
496        let _ = c
497            .address_utxos("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c")
498            .await;
499    }
500
501    // ── BlockAnchorer::verify over a FIXTURE MempoolLookup (no network) ──
502
503    use std::collections::HashMap;
504
505    /// In-memory [`MempoolLookup`] + [`MempoolBroadcast`] — address→UTXO and
506    /// txid→outputs maps. No HTTP. Interior mutability so the broadcast side
507    /// can record raw txs and the anchor round-trip can register the spent
508    /// output's scriptPubKey.
509    #[derive(Clone, Default)]
510    struct FixtureMempool {
511        utxos: std::sync::Arc<std::sync::Mutex<HashMap<String, Vec<Utxo>>>>,
512        txs: std::sync::Arc<std::sync::Mutex<HashMap<String, Vec<TxOut>>>>,
513        broadcasts: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
514    }
515    impl FixtureMempool {
516        fn with_utxo_at(address: &str) -> Self {
517            let me = Self::default();
518            me.utxos.lock().unwrap().insert(
519                address.to_string(),
520                vec![Utxo {
521                    txid: "ab".repeat(32),
522                    vout: 0,
523                    value: 9700,
524                    confirmed: true,
525                    block_height: Some(42_000),
526                }],
527            );
528            me
529        }
530        fn empty() -> Self {
531            Self::default()
532        }
533        fn add_output(&self, txid: &str, vout: u32, spk_hex: &str) {
534            let mut txs = self.txs.lock().unwrap();
535            let outs = txs.entry(txid.to_string()).or_default();
536            while outs.len() <= vout as usize {
537                outs.push(TxOut {
538                    value: 0,
539                    scriptpubkey: None,
540                    scriptpubkey_address: None,
541                });
542            }
543            outs[vout as usize] = TxOut {
544                value: 0,
545                scriptpubkey: Some(spk_hex.to_string()),
546                scriptpubkey_address: None,
547            };
548        }
549    }
550    #[async_trait(?Send)]
551    impl MempoolLookup for FixtureMempool {
552        async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
553            Ok(self
554                .utxos
555                .lock()
556                .unwrap()
557                .get(address)
558                .cloned()
559                .unwrap_or_default())
560        }
561        async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
562            Ok(TxInfo {
563                txid: txid.to_string(),
564                vout: self
565                    .txs
566                    .lock()
567                    .unwrap()
568                    .get(txid)
569                    .cloned()
570                    .unwrap_or_default(),
571                confirmed: true,
572                block_height: Some(42_000),
573            })
574        }
575    }
576    #[async_trait(?Send)]
577    impl MempoolBroadcast for FixtureMempool {
578        async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
579            // Synthetic, stable txid (sha256 of raw hex) — crypto correctness
580            // is asserted elsewhere; the chain-walk only needs uniqueness.
581            let txid = solid_pod_rs::mrc20::sha256_hex(raw_hex);
582            self.broadcasts.lock().unwrap().push(raw_hex.to_string());
583            Ok(txid)
584        }
585    }
586
587    // Issuer keypair (arbitrary) for deriving real anchor addresses.
588    const ISSUER_PRIVKEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
589    fn issuer_pubkey() -> String {
590        let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
591        hex::encode(sk.public_key().to_sec1_bytes())
592    }
593
594    /// Build a `BlockTrailAnchor` whose `address`/`state_strings`/`pubkey`
595    /// are internally consistent (the `address` is the genuine derivation).
596    fn consistent_anchor() -> BlockTrailAnchor {
597        let pubkey = issuer_pubkey();
598        let state_strings = vec!["{\"seq\":0}".to_string(), "{\"seq\":1}".to_string()];
599        let address = bt_address(&pubkey, &state_strings, "testnet4").unwrap();
600        BlockTrailAnchor {
601            ticker: "PROV".into(),
602            state_hash: "ff".repeat(32),
603            txid: "ab".repeat(32),
604            vout: 0,
605            address,
606            network: "testnet4".into(),
607            blockheight: Some(42_000),
608            state_strings,
609            pubkey: Some(pubkey),
610        }
611    }
612
613    #[tokio::test]
614    async fn block_anchorer_verify_true_when_utxo_present() {
615        let anchor = consistent_anchor();
616        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&anchor.address));
617        assert!(
618            anchorer.verify(&anchor).await.unwrap(),
619            "present UTXO ⇒ verify true"
620        );
621    }
622
623    #[tokio::test]
624    async fn block_anchorer_verify_false_when_utxo_absent() {
625        let anchor = consistent_anchor();
626        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
627        assert!(
628            !anchorer.verify(&anchor).await.unwrap(),
629            "absent UTXO ⇒ verify false"
630        );
631    }
632
633    #[tokio::test]
634    async fn block_anchorer_verify_false_when_address_forged() {
635        // A UTXO sits at the real derived address, but the anchor *claims* a
636        // different (forged) address → the re-derivation mismatch fails it.
637        let mut anchor = consistent_anchor();
638        let real = anchor.address.clone();
639        anchor.address = "tb1pforged000000000000000000000000000000".into();
640        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&real));
641        assert!(
642            !anchorer.verify(&anchor).await.unwrap(),
643            "forged address must not verify even with a real UTXO elsewhere"
644        );
645    }
646
647    #[tokio::test]
648    async fn block_anchorer_verify_false_without_pubkey() {
649        // No pubkey ⇒ nothing to re-derive against ⇒ not verifiable.
650        let mut anchor = consistent_anchor();
651        anchor.pubkey = None;
652        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&anchor.address));
653        assert!(!anchorer.verify(&anchor).await.unwrap());
654    }
655
656    #[tokio::test]
657    async fn block_anchorer_anchor_requires_storage() {
658        // The verify-only constructor leaves storage None ⇒ anchor() errors
659        // with a clear message rather than panicking.
660        let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
661        let err = anchorer
662            .anchor("PROV", "deadbeef", "testnet4")
663            .await
664            .unwrap_err();
665        match err {
666            ProvenanceError::Anchor(m) => assert!(m.contains("with_storage")),
667            other => panic!("expected Anchor(requires storage), got {other:?}"),
668        }
669    }
670
671    // ── Phase 4: full anchor() round-trip (mint → store → anchor → verify) ──
672
673    use crate::trail_store::{load_trail, save_trail, StoredTrail};
674    use solid_pod_rs::bitcoin_tx::mint_token;
675    use solid_pod_rs::storage::memory::MemoryBackend;
676    use solid_pod_rs::storage::Storage;
677
678    /// Mint a genesis trail through the write-side, persist it (with the
679    /// issuer secret), and register the genesis UTXO's scriptPubKey so a
680    /// subsequent anchor can spend it. Returns `(storage, mempool, ticker)`.
681    async fn mint_and_store(ticker: &str) -> (std::sync::Arc<dyn Storage>, FixtureMempool, String) {
682        let mempool = FixtureMempool::empty();
683        let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
684
685        // Fund the genesis from an issuer-key voucher (untweaked path).
686        let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
687        let compressed = sk.public_key().to_sec1_bytes();
688        let xonly_hex = hex::encode(&compressed[1..]);
689        let voucher_txid = "11".repeat(32);
690        mempool.add_output(&voucher_txid, 0, &format!("5120{xonly_hex}"));
691
692        let voucher = solid_pod_rs::bitcoin_tx::TxoVoucher {
693            txid: voucher_txid,
694            vout: 0,
695            amount: 100_000,
696            privkey: ISSUER_PRIVKEY.to_string(),
697        };
698        let mint = mint_token(ticker, None, 1_000, &voucher, "testnet4", 300, &mempool)
699            .await
700            .unwrap();
701        let mint_txid = mempool.broadcast_tx(&mint.tx.raw_hex).await.unwrap();
702
703        // Persist the trail with the issuer secret + the broadcast txid.
704        let mut stored = StoredTrail {
705            ticker: mint.trail.ticker.clone(),
706            name: mint.trail.name.clone(),
707            supply: mint.trail.supply,
708            privkey: ISSUER_PRIVKEY.to_string(),
709            pubkey_base: mint.trail.pubkey_base.clone(),
710            states: mint.trail.states.clone(),
711            state_strings: mint.trail.state_strings.clone(),
712            current_txid: mint_txid.clone(),
713            current_vout: 0,
714            current_amount: mint.trail.current_amount,
715            network: mint.trail.network.clone(),
716            date_created: "2026-06-13T00:00:00Z".into(),
717        };
718        stored.current_txid = mint_txid.clone();
719        save_trail(&storage, &stored).await.unwrap();
720
721        // Register the genesis output scriptPubKey so anchor() can spend it.
722        let genesis_xonly = {
723            let chained = solid_pod_rs::mrc20::bt_derive_chained_pubkey(
724                &issuer_pubkey(),
725                std::slice::from_ref(&mint.state_jcs),
726            )
727            .unwrap();
728            hex::encode(&chained[1..])
729        };
730        mempool.add_output(&mint_txid, 0, &format!("5120{genesis_xonly}"));
731
732        (storage, mempool, ticker.to_string())
733    }
734
735    #[tokio::test]
736    async fn block_anchorer_anchor_round_trip_and_self_verifies() {
737        let (storage, mempool, ticker) = mint_and_store("ANCH").await;
738        let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), storage.clone());
739
740        // Anchor a git commit SHA (the provenance write).
741        let commit_sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9001122334";
742        let anchor = anchorer
743            .anchor(&ticker, commit_sha, "testnet4")
744            .await
745            .expect("anchor() must build + broadcast + persist");
746
747        assert_eq!(anchor.ticker, "ANCH");
748        assert_eq!(anchor.state_hash, commit_sha);
749        assert_eq!(anchor.vout, 0);
750        assert!(anchor.blockheight.is_none());
751        assert_eq!(anchor.network, "testnet4");
752        assert!(anchor.pubkey.is_some());
753        // The portable proof carries genesis + anchor state strings.
754        assert_eq!(anchor.state_strings.len(), 2);
755        // The recorded address is the genuine derivation from the proof.
756        let derived = bt_address(
757            anchor.pubkey.as_deref().unwrap(),
758            &anchor.state_strings,
759            "testnet4",
760        )
761        .unwrap();
762        assert_eq!(anchor.address, derived);
763
764        // The trail was persisted with the new state appended + new txid.
765        let reloaded = load_trail(&storage, "ANCH").await.unwrap().unwrap();
766        assert_eq!(reloaded.states.len(), 2);
767        assert_eq!(reloaded.current_txid, anchor.txid);
768        assert_eq!(reloaded.states[1].anchor.as_deref(), Some(commit_sha));
769
770        // verify() ACCEPTS the produced anchor once a UTXO sits at its address.
771        mempool.utxos.lock().unwrap().insert(
772            anchor.address.clone(),
773            vec![Utxo {
774                txid: anchor.txid.clone(),
775                vout: 0,
776                value: 9_400,
777                confirmed: false,
778                block_height: None,
779            }],
780        );
781        assert!(
782            anchorer.verify(&anchor).await.unwrap(),
783            "the anchor we just produced must verify against its own UTXO"
784        );
785    }
786
787    #[tokio::test]
788    async fn block_anchorer_anchor_rejects_unminted_ticker() {
789        let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
790        let anchorer = MempoolBlockAnchorer::with_storage(FixtureMempool::empty(), storage);
791        let err = anchorer
792            .anchor("GHOST", "deadbeef", "testnet4")
793            .await
794            .unwrap_err();
795        match err {
796            ProvenanceError::Anchor(m) => assert!(m.contains("not minted")),
797            other => panic!("expected not-minted error, got {other:?}"),
798        }
799    }
800}