1use async_trait::async_trait;
44use serde::Deserialize;
45
46use solid_pod_rs::bitcoin_tx::{anchor_state, MempoolBroadcast};
47use solid_pod_rs::mrc20::{bt_address, MempoolLookup, TxInfo, TxOut, Utxo};
48use solid_pod_rs::payments::PaymentError;
49use solid_pod_rs::provenance::{BlockAnchorer, BlockTrailAnchor, ProvenanceError};
50
51pub const MEMPOOL_URL_ENV: &str = "JSS_PAY_MEMPOOL_URL";
53
54pub const DEFAULT_MEMPOOL_URL: &str = "https://mempool.space/testnet4";
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum MempoolConfigSource {
78 Explicit,
80 Default,
82}
83
84impl MempoolConfigSource {
85 #[must_use]
88 pub fn as_str(&self) -> &'static str {
89 match self {
90 Self::Explicit => "explicit",
91 Self::Default => "default",
92 }
93 }
94}
95
96impl std::fmt::Display for MempoolConfigSource {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.write_str(self.as_str())
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum BitcoinNetwork {
110 Mainnet,
112 Testnet3,
114 Testnet4,
116 Signet,
118 Regtest,
120 Unknown,
122}
123
124impl BitcoinNetwork {
125 #[must_use]
127 pub fn as_str(&self) -> &'static str {
128 match self {
129 Self::Mainnet => "mainnet",
130 Self::Testnet3 => "testnet3",
131 Self::Testnet4 => "testnet4",
132 Self::Signet => "signet",
133 Self::Regtest => "regtest",
134 Self::Unknown => "unknown",
135 }
136 }
137}
138
139impl std::fmt::Display for BitcoinNetwork {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 f.write_str(self.as_str())
142 }
143}
144
145#[must_use]
164pub fn infer_network(base_url: &str) -> BitcoinNetwork {
165 let trimmed = base_url.trim().trim_end_matches('/');
166
167 let after_scheme = match trimmed.find("://") {
170 Some(i) => &trimmed[i + 3..],
171 None => trimmed,
172 };
173 let (authority, path) = match after_scheme.find('/') {
174 Some(i) => (&after_scheme[..i], &after_scheme[i + 1..]),
175 None => (after_scheme, ""),
176 };
177
178 let path = path
180 .split(['?', '#'])
181 .next()
182 .unwrap_or("")
183 .trim_end_matches('/');
184 let last_segment = path.rsplit('/').find(|seg| !seg.is_empty()).unwrap_or("");
185
186 match last_segment.to_ascii_lowercase().as_str() {
187 "testnet4" => return BitcoinNetwork::Testnet4,
188 "testnet" | "testnet3" => return BitcoinNetwork::Testnet3,
189 "signet" => return BitcoinNetwork::Signet,
190 "regtest" => return BitcoinNetwork::Regtest,
191 "mainnet" | "bitcoin" => return BitcoinNetwork::Mainnet,
192 _ => {}
193 }
194
195 let host = authority.rsplit('@').next().unwrap_or(authority);
197 let host = if let Some(rest) = host.strip_prefix('[') {
198 rest.split(']').next().unwrap_or(rest)
200 } else {
201 host.split(':').next().unwrap_or(host)
202 };
203 let host_lower = host.to_ascii_lowercase();
204
205 let is_loopback = host_lower == "localhost"
206 || host_lower == "::1"
207 || host_lower.starts_with("127.")
208 || host_lower.ends_with(".localhost");
209 if is_loopback {
210 return BitcoinNetwork::Regtest;
212 }
213
214 if path.is_empty() && (host_lower == "mempool.space" || host_lower.ends_with(".mempool.space"))
216 {
217 return BitcoinNetwork::Mainnet;
218 }
219
220 BitcoinNetwork::Unknown
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct MempoolSelection {
231 pub base_url: String,
233 pub network: BitcoinNetwork,
235 pub source: MempoolConfigSource,
237}
238
239impl MempoolSelection {
240 #[must_use]
243 pub fn to_manifest_json(&self) -> serde_json::Value {
244 serde_json::json!({
245 "base_url": self.base_url,
246 "network": self.network.as_str(),
247 "source": self.source.as_str(),
248 })
249 }
250}
251
252#[must_use]
260pub fn select_mempool_endpoint(configured: Option<&str>) -> MempoolSelection {
261 let (raw, source) = match configured.map(str::trim).filter(|v| !v.is_empty()) {
262 Some(v) => (v, MempoolConfigSource::Explicit),
263 None => (DEFAULT_MEMPOOL_URL, MempoolConfigSource::Default),
264 };
265 let base_url = raw.trim_end_matches('/').to_string();
266 let network = infer_network(&base_url);
267 MempoolSelection {
268 base_url,
269 network,
270 source,
271 }
272}
273
274#[must_use]
277pub fn select_mempool_endpoint_from_env() -> MempoolSelection {
278 let configured = std::env::var(MEMPOOL_URL_ENV).ok();
279 select_mempool_endpoint(configured.as_deref())
280}
281
282pub fn log_mempool_selection(sel: &MempoolSelection) {
290 tracing::info!(
291 target: "solid_pod_rs_server::mempool",
292 base_url = %sel.base_url,
293 network = sel.network.as_str(),
294 source = sel.source.as_str(),
295 "mempool endpoint selected"
296 );
297 if sel.network == BitcoinNetwork::Unknown || sel.source == MempoolConfigSource::Default {
298 tracing::warn!(
299 target: "solid_pod_rs_server::mempool",
300 base_url = %sel.base_url,
301 network = sel.network.as_str(),
302 source = sel.source.as_str(),
303 env_var = MEMPOOL_URL_ENV,
304 "pod is using an unverified or defaulted Bitcoin endpoint; anchors may be \
305 written to or verified against an unintended chain — set {} to pin the \
306 explorer, and therefore the network, explicitly",
307 MEMPOOL_URL_ENV
308 );
309 }
310}
311
312pub fn log_mempool_selection_once(sel: &MempoolSelection) {
326 static ONCE: std::sync::Once = std::sync::Once::new();
327 ONCE.call_once(|| log_mempool_selection(sel));
328}
329
330#[derive(Debug, Clone)]
336pub struct MempoolHttpClient {
337 client: reqwest::Client,
338 base: String,
341 selection: MempoolSelection,
345}
346
347impl MempoolHttpClient {
348 #[must_use]
351 pub fn new(base_url: impl Into<String>) -> Self {
352 let raw = base_url.into();
353 let base = raw.trim_end_matches('/').to_string();
354 let selection = MempoolSelection {
357 network: infer_network(&base),
358 base_url: base,
359 source: MempoolConfigSource::Explicit,
360 };
361 Self::from_selection(selection)
362 }
363
364 #[must_use]
368 pub fn from_selection(selection: MempoolSelection) -> Self {
369 Self {
370 client: reqwest::Client::new(),
371 base: selection.base_url.clone(),
372 selection,
373 }
374 }
375
376 #[must_use]
384 pub fn from_env() -> Self {
385 let selection = select_mempool_endpoint_from_env();
386 log_mempool_selection_once(&selection);
387 Self::from_selection(selection)
388 }
389
390 #[must_use]
392 pub fn base_url(&self) -> &str {
393 &self.base
394 }
395
396 #[must_use]
399 pub fn selection(&self) -> &MempoolSelection {
400 &self.selection
401 }
402
403 pub async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError> {
407 let url = format!("{}/api/tx/{txid}", self.base);
408 let resp = self
409 .client
410 .get(&url)
411 .send()
412 .await
413 .map_err(|e| PaymentError::InvalidState(format!("mempool request failed: {e}")))?;
414 if resp.status() == reqwest::StatusCode::NOT_FOUND {
415 return Ok(false);
416 }
417 if !resp.status().is_success() {
418 return Err(PaymentError::InvalidState(format!(
419 "mempool API error: {} for {url}",
420 resp.status().as_u16()
421 )));
422 }
423 Ok(true)
424 }
425
426 async fn get_text(&self, url: &str) -> Result<String, PaymentError> {
429 let resp = self
430 .client
431 .get(url)
432 .send()
433 .await
434 .map_err(|e| PaymentError::InvalidState(format!("mempool request failed: {e}")))?;
435 let status = resp.status();
436 if !status.is_success() {
437 return Err(PaymentError::InvalidState(format!(
438 "mempool API error: {} for {url}",
439 status.as_u16()
440 )));
441 }
442 resp.text()
443 .await
444 .map_err(|e| PaymentError::InvalidState(format!("mempool body read failed: {e}")))
445 }
446
447 async fn post_text(&self, url: &str, body: &str) -> Result<String, PaymentError> {
452 let resp = self
453 .client
454 .post(url)
455 .header("Content-Type", "text/plain")
456 .body(body.to_string())
457 .send()
458 .await
459 .map_err(|e| PaymentError::InvalidState(format!("mempool broadcast failed: {e}")))?;
460 let status = resp.status();
461 let text = resp
462 .text()
463 .await
464 .map_err(|e| PaymentError::InvalidState(format!("mempool body read failed: {e}")))?;
465 if !status.is_success() {
466 return Err(PaymentError::InvalidState(format!(
467 "broadcast rejected ({}): {text}",
468 status.as_u16()
469 )));
470 }
471 Ok(text.trim().to_string())
472 }
473}
474
475#[derive(Debug, Deserialize, Default)]
479struct StatusWire {
480 #[serde(default)]
481 confirmed: bool,
482 #[serde(default)]
483 block_height: Option<u64>,
484}
485
486#[derive(Debug, Deserialize)]
488struct UtxoWire {
489 txid: String,
490 vout: u32,
491 #[serde(default)]
492 value: u64,
493 #[serde(default)]
494 status: StatusWire,
495}
496
497impl From<UtxoWire> for Utxo {
498 fn from(w: UtxoWire) -> Self {
499 Utxo {
500 txid: w.txid,
501 vout: w.vout,
502 value: w.value,
503 confirmed: w.status.confirmed,
504 block_height: w.status.block_height,
505 }
506 }
507}
508
509#[derive(Debug, Deserialize, Default)]
511struct TxOutWire {
512 #[serde(default)]
513 value: u64,
514 #[serde(default)]
515 scriptpubkey: Option<String>,
516 #[serde(default)]
517 scriptpubkey_address: Option<String>,
518}
519
520impl From<TxOutWire> for TxOut {
521 fn from(w: TxOutWire) -> Self {
522 TxOut {
523 value: w.value,
524 scriptpubkey: w.scriptpubkey,
525 scriptpubkey_address: w.scriptpubkey_address,
526 }
527 }
528}
529
530#[derive(Debug, Deserialize)]
532struct TxWire {
533 txid: String,
534 #[serde(default)]
535 vout: Vec<TxOutWire>,
536 #[serde(default)]
537 status: StatusWire,
538}
539
540impl From<TxWire> for TxInfo {
541 fn from(w: TxWire) -> Self {
542 TxInfo {
543 txid: w.txid,
544 vout: w.vout.into_iter().map(TxOut::from).collect(),
545 confirmed: w.status.confirmed,
546 block_height: w.status.block_height,
547 }
548 }
549}
550
551#[async_trait(?Send)]
552impl MempoolLookup for MempoolHttpClient {
553 async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
554 let url = format!("{}/api/address/{address}/utxo", self.base);
555 let body = self.get_text(&url).await?;
556 let wire: Vec<UtxoWire> = serde_json::from_str(&body)
557 .map_err(|e| PaymentError::InvalidState(format!("malformed utxo JSON: {e}")))?;
558 Ok(wire.into_iter().map(Utxo::from).collect())
559 }
560
561 async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
562 let url = format!("{}/api/tx/{txid}", self.base);
563 let body = self.get_text(&url).await?;
564 let wire: TxWire = serde_json::from_str(&body)
565 .map_err(|e| PaymentError::InvalidState(format!("malformed tx JSON: {e}")))?;
566 Ok(TxInfo::from(wire))
567 }
568}
569
570#[async_trait(?Send)]
571impl MempoolBroadcast for MempoolHttpClient {
572 async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
573 let url = format!("{}/api/tx", self.base);
574 self.post_text(&url, raw_hex).await
575 }
576}
577
578#[derive(Clone)]
601pub struct MempoolBlockAnchorer<M: MempoolLookup + MempoolBroadcast + Send + Sync> {
602 lookup: M,
603 storage: Option<std::sync::Arc<dyn solid_pod_rs::storage::Storage>>,
604}
605
606impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> MempoolBlockAnchorer<M> {
607 pub fn new(lookup: M) -> Self {
611 Self {
612 lookup,
613 storage: None,
614 }
615 }
616
617 pub fn with_storage(
621 lookup: M,
622 storage: std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
623 ) -> Self {
624 Self {
625 lookup,
626 storage: Some(storage),
627 }
628 }
629
630 pub fn lookup(&self) -> &M {
632 &self.lookup
633 }
634}
635
636#[async_trait(?Send)]
637impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> BlockAnchorer for MempoolBlockAnchorer<M> {
638 async fn anchor(
648 &self,
649 ticker: &str,
650 state_hash: &str,
651 network: &str,
652 ) -> Result<BlockTrailAnchor, ProvenanceError> {
653 use crate::trail_store::{load_trail, save_trail};
654 use solid_pod_rs::bitcoin_tx::DEFAULT_FEE_SATS;
655
656 let storage = self.storage.as_ref().ok_or_else(|| {
657 ProvenanceError::Anchor(
658 "anchor() requires storage; construct with MempoolBlockAnchorer::with_storage"
659 .into(),
660 )
661 })?;
662
663 let mut stored = load_trail(storage, ticker)
665 .await
666 .map_err(|e| ProvenanceError::Anchor(format!("load trail {ticker}: {e}")))?
667 .ok_or_else(|| {
668 ProvenanceError::Anchor(format!("trail {ticker} not minted on this pod"))
669 })?;
670
671 if stored.network != network {
672 return Err(ProvenanceError::Anchor(format!(
673 "network mismatch: trail is {}, requested {network}",
674 stored.network
675 )));
676 }
677
678 let public = stored.to_public();
680 let update = anchor_state(
681 &public,
682 &stored.privkey,
683 state_hash,
684 DEFAULT_FEE_SATS,
685 &self.lookup,
686 )
687 .await
688 .map_err(|e| ProvenanceError::Anchor(format!("build anchoring tx: {e}")))?;
689
690 let txid = self
692 .lookup
693 .broadcast_tx(&update.tx.raw_hex)
694 .await
695 .map_err(|e| ProvenanceError::Anchor(format!("broadcast anchoring tx: {e}")))?;
696
697 let mut appended = update.trail.clone();
700 appended.current_txid = txid.clone();
701 stored.merge_public(&appended);
702 stored.current_txid = txid.clone();
703 stored.current_vout = 0;
704 save_trail(storage, &stored)
705 .await
706 .map_err(|e| ProvenanceError::Anchor(format!("save trail: {e}")))?;
707
708 Ok(BlockTrailAnchor {
709 ticker: ticker.to_string(),
710 state_hash: state_hash.to_string(),
711 txid,
712 vout: 0,
713 address: update.address,
714 network: network.to_string(),
715 blockheight: None,
716 state_strings: appended.state_strings,
717 pubkey: Some(stored.pubkey_base),
718 })
719 }
720
721 async fn verify(&self, anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
722 let Some(pubkey) = anchor.pubkey.as_deref() else {
726 return Ok(false);
727 };
728 if anchor.state_strings.is_empty() {
729 return Ok(false);
730 }
731
732 let derived = bt_address(pubkey, &anchor.state_strings, &anchor.network)
735 .map_err(|e| ProvenanceError::Anchor(format!("address re-derivation failed: {e}")))?;
736 if derived != anchor.address {
737 return Ok(false);
738 }
739
740 let utxos = self
742 .lookup
743 .address_utxos(&derived)
744 .await
745 .map_err(|e| ProvenanceError::Anchor(format!("mempool lookup failed: {e}")))?;
746 Ok(!utxos.is_empty())
747 }
748}
749
750#[cfg(test)]
755mod tests {
756 use super::*;
757
758 const UTXO_JSON: &str = include_str!("../tests/fixtures/mempool/address_utxos.json");
761 const TX_JSON: &str = include_str!("../tests/fixtures/mempool/tx.json");
763 const EMPTY_UTXO_JSON: &str = "[]";
765
766 #[test]
767 fn utxo_wire_flattens_status() {
768 let wire: Vec<UtxoWire> = serde_json::from_str(UTXO_JSON).unwrap();
769 let utxos: Vec<Utxo> = wire.into_iter().map(Utxo::from).collect();
770 assert_eq!(utxos.len(), 1);
771 assert_eq!(utxos[0].vout, 0);
772 assert_eq!(utxos[0].value, 9700);
773 assert!(
774 utxos[0].confirmed,
775 "status.confirmed must flatten onto Utxo"
776 );
777 assert_eq!(utxos[0].block_height, Some(42_000));
778 }
779
780 #[test]
781 fn empty_utxo_set_parses_to_empty_vec() {
782 let wire: Vec<UtxoWire> = serde_json::from_str(EMPTY_UTXO_JSON).unwrap();
783 assert!(wire.is_empty());
784 }
785
786 #[test]
787 fn tx_wire_flattens_outputs_and_status() {
788 let wire: TxWire = serde_json::from_str(TX_JSON).unwrap();
789 let tx = TxInfo::from(wire);
790 assert_eq!(tx.vout.len(), 2);
791 assert_eq!(tx.vout[0].value, 9700);
792 assert_eq!(
793 tx.vout[0].scriptpubkey.as_deref(),
794 Some("5120aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899")
795 );
796 assert_eq!(
797 tx.vout[0].scriptpubkey_address.as_deref(),
798 Some("tb1pexampleaddress")
799 );
800 assert!(tx.confirmed);
801 assert_eq!(tx.block_height, Some(42_000));
802 }
803
804 #[test]
805 fn from_env_defaults_to_testnet4() {
806 let prev = std::env::var(MEMPOOL_URL_ENV).ok();
808 std::env::remove_var(MEMPOOL_URL_ENV);
809 let c = MempoolHttpClient::from_env();
810 assert_eq!(c.base_url(), DEFAULT_MEMPOOL_URL);
811 if let Some(v) = prev {
812 std::env::set_var(MEMPOOL_URL_ENV, v);
813 }
814 }
815
816 #[test]
817 fn new_trims_trailing_slash() {
818 let c = MempoolHttpClient::new("https://mempool.space/testnet4/");
819 assert_eq!(c.base_url(), "https://mempool.space/testnet4");
820 }
821
822 #[ignore = "hits live mempool.space; opt-in only"]
825 #[tokio::test]
826 async fn live_address_utxos_smoke() {
827 let c = MempoolHttpClient::from_env();
828 let _ = c
831 .address_utxos("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c")
832 .await;
833 }
834
835 use std::collections::HashMap;
838
839 #[derive(Clone, Default)]
844 struct FixtureMempool {
845 utxos: std::sync::Arc<std::sync::Mutex<HashMap<String, Vec<Utxo>>>>,
846 txs: std::sync::Arc<std::sync::Mutex<HashMap<String, Vec<TxOut>>>>,
847 broadcasts: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
848 }
849 impl FixtureMempool {
850 fn with_utxo_at(address: &str) -> Self {
851 let me = Self::default();
852 me.utxos.lock().unwrap().insert(
853 address.to_string(),
854 vec![Utxo {
855 txid: "ab".repeat(32),
856 vout: 0,
857 value: 9700,
858 confirmed: true,
859 block_height: Some(42_000),
860 }],
861 );
862 me
863 }
864 fn empty() -> Self {
865 Self::default()
866 }
867 fn add_output(&self, txid: &str, vout: u32, spk_hex: &str) {
868 let mut txs = self.txs.lock().unwrap();
869 let outs = txs.entry(txid.to_string()).or_default();
870 while outs.len() <= vout as usize {
871 outs.push(TxOut {
872 value: 0,
873 scriptpubkey: None,
874 scriptpubkey_address: None,
875 });
876 }
877 outs[vout as usize] = TxOut {
878 value: 0,
879 scriptpubkey: Some(spk_hex.to_string()),
880 scriptpubkey_address: None,
881 };
882 }
883 }
884 #[async_trait(?Send)]
885 impl MempoolLookup for FixtureMempool {
886 async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
887 Ok(self
888 .utxos
889 .lock()
890 .unwrap()
891 .get(address)
892 .cloned()
893 .unwrap_or_default())
894 }
895 async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
896 Ok(TxInfo {
897 txid: txid.to_string(),
898 vout: self
899 .txs
900 .lock()
901 .unwrap()
902 .get(txid)
903 .cloned()
904 .unwrap_or_default(),
905 confirmed: true,
906 block_height: Some(42_000),
907 })
908 }
909 }
910 #[async_trait(?Send)]
911 impl MempoolBroadcast for FixtureMempool {
912 async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
913 let txid = solid_pod_rs::mrc20::sha256_hex(raw_hex);
916 self.broadcasts.lock().unwrap().push(raw_hex.to_string());
917 Ok(txid)
918 }
919 }
920
921 const ISSUER_PRIVKEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
923 fn issuer_pubkey() -> String {
924 let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
925 hex::encode(sk.public_key().to_sec1_bytes())
926 }
927
928 fn consistent_anchor() -> BlockTrailAnchor {
931 let pubkey = issuer_pubkey();
932 let state_strings = vec!["{\"seq\":0}".to_string(), "{\"seq\":1}".to_string()];
933 let address = bt_address(&pubkey, &state_strings, "testnet4").unwrap();
934 BlockTrailAnchor {
935 ticker: "PROV".into(),
936 state_hash: "ff".repeat(32),
937 txid: "ab".repeat(32),
938 vout: 0,
939 address,
940 network: "testnet4".into(),
941 blockheight: Some(42_000),
942 state_strings,
943 pubkey: Some(pubkey),
944 }
945 }
946
947 #[tokio::test]
948 async fn block_anchorer_verify_true_when_utxo_present() {
949 let anchor = consistent_anchor();
950 let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&anchor.address));
951 assert!(
952 anchorer.verify(&anchor).await.unwrap(),
953 "present UTXO ⇒ verify true"
954 );
955 }
956
957 #[tokio::test]
958 async fn block_anchorer_verify_false_when_utxo_absent() {
959 let anchor = consistent_anchor();
960 let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
961 assert!(
962 !anchorer.verify(&anchor).await.unwrap(),
963 "absent UTXO ⇒ verify false"
964 );
965 }
966
967 #[tokio::test]
968 async fn block_anchorer_verify_false_when_address_forged() {
969 let mut anchor = consistent_anchor();
972 let real = anchor.address.clone();
973 anchor.address = "tb1pforged000000000000000000000000000000".into();
974 let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&real));
975 assert!(
976 !anchorer.verify(&anchor).await.unwrap(),
977 "forged address must not verify even with a real UTXO elsewhere"
978 );
979 }
980
981 #[tokio::test]
982 async fn block_anchorer_verify_false_without_pubkey() {
983 let mut anchor = consistent_anchor();
985 anchor.pubkey = None;
986 let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&anchor.address));
987 assert!(!anchorer.verify(&anchor).await.unwrap());
988 }
989
990 #[tokio::test]
991 async fn block_anchorer_anchor_requires_storage() {
992 let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
995 let err = anchorer
996 .anchor("PROV", "deadbeef", "testnet4")
997 .await
998 .unwrap_err();
999 match err {
1000 ProvenanceError::Anchor(m) => assert!(m.contains("with_storage")),
1001 other => panic!("expected Anchor(requires storage), got {other:?}"),
1002 }
1003 }
1004
1005 use crate::trail_store::{load_trail, save_trail, StoredTrail};
1008 use solid_pod_rs::bitcoin_tx::mint_token;
1009 use solid_pod_rs::storage::memory::MemoryBackend;
1010 use solid_pod_rs::storage::Storage;
1011
1012 async fn mint_and_store(ticker: &str) -> (std::sync::Arc<dyn Storage>, FixtureMempool, String) {
1016 let mempool = FixtureMempool::empty();
1017 let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
1018
1019 let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
1021 let compressed = sk.public_key().to_sec1_bytes();
1022 let xonly_hex = hex::encode(&compressed[1..]);
1023 let voucher_txid = "11".repeat(32);
1024 mempool.add_output(&voucher_txid, 0, &format!("5120{xonly_hex}"));
1025
1026 let voucher = solid_pod_rs::bitcoin_tx::TxoVoucher {
1027 txid: voucher_txid,
1028 vout: 0,
1029 amount: 100_000,
1030 privkey: ISSUER_PRIVKEY.to_string(),
1031 };
1032 let mint = mint_token(ticker, None, 1_000, &voucher, "testnet4", 300, &mempool)
1033 .await
1034 .unwrap();
1035 let mint_txid = mempool.broadcast_tx(&mint.tx.raw_hex).await.unwrap();
1036
1037 let mut stored = StoredTrail {
1039 ticker: mint.trail.ticker.clone(),
1040 name: mint.trail.name.clone(),
1041 supply: mint.trail.supply,
1042 privkey: ISSUER_PRIVKEY.to_string(),
1043 pubkey_base: mint.trail.pubkey_base.clone(),
1044 states: mint.trail.states.clone(),
1045 state_strings: mint.trail.state_strings.clone(),
1046 current_txid: mint_txid.clone(),
1047 current_vout: 0,
1048 current_amount: mint.trail.current_amount,
1049 network: mint.trail.network.clone(),
1050 date_created: "2026-06-13T00:00:00Z".into(),
1051 };
1052 stored.current_txid = mint_txid.clone();
1053 save_trail(&storage, &stored).await.unwrap();
1054
1055 let genesis_xonly = {
1057 let chained = solid_pod_rs::mrc20::bt_derive_chained_pubkey(
1058 &issuer_pubkey(),
1059 std::slice::from_ref(&mint.state_jcs),
1060 )
1061 .unwrap();
1062 hex::encode(&chained[1..])
1063 };
1064 mempool.add_output(&mint_txid, 0, &format!("5120{genesis_xonly}"));
1065
1066 (storage, mempool, ticker.to_string())
1067 }
1068
1069 #[tokio::test]
1070 async fn block_anchorer_anchor_round_trip_and_self_verifies() {
1071 let (storage, mempool, ticker) = mint_and_store("ANCH").await;
1072 let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), storage.clone());
1073
1074 let commit_sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9001122334";
1076 let anchor = anchorer
1077 .anchor(&ticker, commit_sha, "testnet4")
1078 .await
1079 .expect("anchor() must build + broadcast + persist");
1080
1081 assert_eq!(anchor.ticker, "ANCH");
1082 assert_eq!(anchor.state_hash, commit_sha);
1083 assert_eq!(anchor.vout, 0);
1084 assert!(anchor.blockheight.is_none());
1085 assert_eq!(anchor.network, "testnet4");
1086 assert!(anchor.pubkey.is_some());
1087 assert_eq!(anchor.state_strings.len(), 2);
1089 let derived = bt_address(
1091 anchor.pubkey.as_deref().unwrap(),
1092 &anchor.state_strings,
1093 "testnet4",
1094 )
1095 .unwrap();
1096 assert_eq!(anchor.address, derived);
1097
1098 let reloaded = load_trail(&storage, "ANCH").await.unwrap().unwrap();
1100 assert_eq!(reloaded.states.len(), 2);
1101 assert_eq!(reloaded.current_txid, anchor.txid);
1102 assert_eq!(reloaded.states[1].anchor.as_deref(), Some(commit_sha));
1103
1104 mempool.utxos.lock().unwrap().insert(
1106 anchor.address.clone(),
1107 vec![Utxo {
1108 txid: anchor.txid.clone(),
1109 vout: 0,
1110 value: 9_400,
1111 confirmed: false,
1112 block_height: None,
1113 }],
1114 );
1115 assert!(
1116 anchorer.verify(&anchor).await.unwrap(),
1117 "the anchor we just produced must verify against its own UTXO"
1118 );
1119 }
1120
1121 #[tokio::test]
1122 async fn block_anchorer_anchor_rejects_unminted_ticker() {
1123 let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
1124 let anchorer = MempoolBlockAnchorer::with_storage(FixtureMempool::empty(), storage);
1125 let err = anchorer
1126 .anchor("GHOST", "deadbeef", "testnet4")
1127 .await
1128 .unwrap_err();
1129 match err {
1130 ProvenanceError::Anchor(m) => assert!(m.contains("not minted")),
1131 other => panic!("expected not-minted error, got {other:?}"),
1132 }
1133 }
1134}