1use 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
43pub const MEMPOOL_URL_ENV: &str = "JSS_PAY_MEMPOOL_URL";
45
46pub const DEFAULT_MEMPOOL_URL: &str = "https://mempool.space/testnet4";
49
50#[derive(Debug, Clone)]
56pub struct MempoolHttpClient {
57 client: reqwest::Client,
58 base: String,
61}
62
63impl MempoolHttpClient {
64 #[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 #[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 #[must_use]
88 pub fn base_url(&self) -> &str {
89 &self.base
90 }
91
92 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 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#[derive(Debug, Deserialize, Default)]
145struct StatusWire {
146 #[serde(default)]
147 confirmed: bool,
148 #[serde(default)]
149 block_height: Option<u64>,
150}
151
152#[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#[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#[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#[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 pub fn new(lookup: M) -> Self {
277 Self {
278 lookup,
279 storage: None,
280 }
281 }
282
283 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 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 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 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 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 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 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 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 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 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#[cfg(test)]
421mod tests {
422 use super::*;
423
424 const UTXO_JSON: &str = include_str!("../tests/fixtures/mempool/address_utxos.json");
427 const TX_JSON: &str = include_str!("../tests/fixtures/mempool/tx.json");
429 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 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 #[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 let _ = c
497 .address_utxos("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c")
498 .await;
499 }
500
501 use std::collections::HashMap;
504
505 #[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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(anchor.state_strings.len(), 2);
755 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 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 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}