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 { lookup, storage: None }
278 }
279
280 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 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 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 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 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 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 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 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 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 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#[cfg(test)]
410mod tests {
411 use super::*;
412
413 const UTXO_JSON: &str = include_str!("../tests/fixtures/mempool/address_utxos.json");
416 const TX_JSON: &str = include_str!("../tests/fixtures/mempool/tx.json");
418 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 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 #[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 let _ = c
483 .address_utxos("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c")
484 .await;
485 }
486
487 use std::collections::HashMap;
490
491 #[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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(anchor.state_strings.len(), 2);
719 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 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 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}