1use async_trait::async_trait;
44use serde::{Deserialize, Serialize};
45use std::time::Duration;
46
47use solid_pod_rs::bitcoin_tx::{
48 anchor_state, gitmark_advance, gitmark_genesis, GitmarkUpdate, MempoolBroadcast, TxoVoucher,
49 DEFAULT_FEE_SATS,
50};
51use solid_pod_rs::blocktrail::{verify_anchor_chain, TrailReport};
52use solid_pod_rs::mrc20::{bt_address, MempoolLookup, TxIn, TxInfo, TxOut, Utxo};
53use solid_pod_rs::payments::PaymentError;
54use solid_pod_rs::provenance::{BlockAnchorer, BlockTrailAnchor, ProvenanceError};
55
56pub const MEMPOOL_URL_ENV: &str = "JSS_PAY_MEMPOOL_URL";
58
59pub const DEFAULT_MEMPOOL_URL: &str = "https://mempool.space/testnet4";
62
63pub const MEMPOOL_CONNECT_TIMEOUT_ENV: &str = "JSS_PAY_MEMPOOL_CONNECT_TIMEOUT_SECS";
65
66pub const MEMPOOL_TIMEOUT_ENV: &str = "JSS_PAY_MEMPOOL_TIMEOUT_SECS";
69
70const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 5;
72
73const DEFAULT_TIMEOUT_SECS: u64 = 20;
75
76fn env_secs(var: &str, default: u64) -> Duration {
78 let secs = std::env::var(var)
79 .ok()
80 .and_then(|v| v.trim().parse::<u64>().ok())
81 .filter(|s| *s > 0)
82 .unwrap_or(default);
83 Duration::from_secs(secs)
84}
85
86fn http_client(connect: Duration, total: Duration) -> reqwest::Client {
88 reqwest::Client::builder()
89 .connect_timeout(connect)
90 .timeout(total)
91 .build()
92 .unwrap_or_default()
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum MempoolConfigSource {
115 Explicit,
117 Default,
119}
120
121impl MempoolConfigSource {
122 #[must_use]
125 pub fn as_str(&self) -> &'static str {
126 match self {
127 Self::Explicit => "explicit",
128 Self::Default => "default",
129 }
130 }
131}
132
133impl std::fmt::Display for MempoolConfigSource {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.write_str(self.as_str())
136 }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum BitcoinNetwork {
147 Mainnet,
149 Testnet3,
151 Testnet4,
153 Signet,
155 Regtest,
157 Unknown,
159}
160
161impl BitcoinNetwork {
162 #[must_use]
164 pub fn as_str(&self) -> &'static str {
165 match self {
166 Self::Mainnet => "mainnet",
167 Self::Testnet3 => "testnet3",
168 Self::Testnet4 => "testnet4",
169 Self::Signet => "signet",
170 Self::Regtest => "regtest",
171 Self::Unknown => "unknown",
172 }
173 }
174}
175
176impl std::fmt::Display for BitcoinNetwork {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 f.write_str(self.as_str())
179 }
180}
181
182#[must_use]
201pub fn infer_network(base_url: &str) -> BitcoinNetwork {
202 let trimmed = base_url.trim().trim_end_matches('/');
203
204 let after_scheme = match trimmed.find("://") {
207 Some(i) => &trimmed[i + 3..],
208 None => trimmed,
209 };
210 let (authority, path) = match after_scheme.find('/') {
211 Some(i) => (&after_scheme[..i], &after_scheme[i + 1..]),
212 None => (after_scheme, ""),
213 };
214
215 let path = path
217 .split(['?', '#'])
218 .next()
219 .unwrap_or("")
220 .trim_end_matches('/');
221 let last_segment = path.rsplit('/').find(|seg| !seg.is_empty()).unwrap_or("");
222
223 match last_segment.to_ascii_lowercase().as_str() {
224 "testnet4" => return BitcoinNetwork::Testnet4,
225 "testnet" | "testnet3" => return BitcoinNetwork::Testnet3,
226 "signet" => return BitcoinNetwork::Signet,
227 "regtest" => return BitcoinNetwork::Regtest,
228 "mainnet" | "bitcoin" => return BitcoinNetwork::Mainnet,
229 _ => {}
230 }
231
232 let host = authority.rsplit('@').next().unwrap_or(authority);
234 let host = if let Some(rest) = host.strip_prefix('[') {
235 rest.split(']').next().unwrap_or(rest)
237 } else {
238 host.split(':').next().unwrap_or(host)
239 };
240 let host_lower = host.to_ascii_lowercase();
241
242 let is_loopback = host_lower == "localhost"
243 || host_lower == "::1"
244 || host_lower.starts_with("127.")
245 || host_lower.ends_with(".localhost");
246 if is_loopback {
247 return BitcoinNetwork::Regtest;
249 }
250
251 if path.is_empty() && (host_lower == "mempool.space" || host_lower.ends_with(".mempool.space"))
253 {
254 return BitcoinNetwork::Mainnet;
255 }
256
257 BitcoinNetwork::Unknown
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct MempoolSelection {
268 pub base_url: String,
270 pub network: BitcoinNetwork,
272 pub source: MempoolConfigSource,
274}
275
276impl MempoolSelection {
277 #[must_use]
280 pub fn to_manifest_json(&self) -> serde_json::Value {
281 serde_json::json!({
282 "base_url": self.base_url,
283 "network": self.network.as_str(),
284 "source": self.source.as_str(),
285 })
286 }
287}
288
289#[must_use]
297pub fn select_mempool_endpoint(configured: Option<&str>) -> MempoolSelection {
298 let (raw, source) = match configured.map(str::trim).filter(|v| !v.is_empty()) {
299 Some(v) => (v, MempoolConfigSource::Explicit),
300 None => (DEFAULT_MEMPOOL_URL, MempoolConfigSource::Default),
301 };
302 let base_url = raw.trim_end_matches('/').to_string();
303 let network = infer_network(&base_url);
304 MempoolSelection {
305 base_url,
306 network,
307 source,
308 }
309}
310
311#[must_use]
314pub fn select_mempool_endpoint_from_env() -> MempoolSelection {
315 let configured = std::env::var(MEMPOOL_URL_ENV).ok();
316 select_mempool_endpoint(configured.as_deref())
317}
318
319pub fn log_mempool_selection(sel: &MempoolSelection) {
327 tracing::info!(
328 target: "solid_pod_rs_server::mempool",
329 base_url = %sel.base_url,
330 network = sel.network.as_str(),
331 source = sel.source.as_str(),
332 "mempool endpoint selected"
333 );
334 if sel.network == BitcoinNetwork::Unknown || sel.source == MempoolConfigSource::Default {
335 tracing::warn!(
336 target: "solid_pod_rs_server::mempool",
337 base_url = %sel.base_url,
338 network = sel.network.as_str(),
339 source = sel.source.as_str(),
340 env_var = MEMPOOL_URL_ENV,
341 "pod is using an unverified or defaulted Bitcoin endpoint; anchors may be \
342 written to or verified against an unintended chain — set {} to pin the \
343 explorer, and therefore the network, explicitly",
344 MEMPOOL_URL_ENV
345 );
346 }
347}
348
349pub fn log_mempool_selection_once(sel: &MempoolSelection) {
363 static ONCE: std::sync::Once = std::sync::Once::new();
364 ONCE.call_once(|| log_mempool_selection(sel));
365}
366
367#[derive(Debug, Clone)]
373pub struct MempoolHttpClient {
374 client: reqwest::Client,
375 base: String,
378 selection: MempoolSelection,
382}
383
384impl MempoolHttpClient {
385 #[must_use]
388 pub fn new(base_url: impl Into<String>) -> Self {
389 let raw = base_url.into();
390 let base = raw.trim_end_matches('/').to_string();
391 let selection = MempoolSelection {
394 network: infer_network(&base),
395 base_url: base,
396 source: MempoolConfigSource::Explicit,
397 };
398 Self::from_selection(selection)
399 }
400
401 #[must_use]
405 pub fn from_selection(selection: MempoolSelection) -> Self {
406 Self {
407 client: http_client(
408 env_secs(MEMPOOL_CONNECT_TIMEOUT_ENV, DEFAULT_CONNECT_TIMEOUT_SECS),
409 env_secs(MEMPOOL_TIMEOUT_ENV, DEFAULT_TIMEOUT_SECS),
410 ),
411 base: selection.base_url.clone(),
412 selection,
413 }
414 }
415
416 #[must_use]
421 pub fn with_timeouts(mut self, connect: Duration, total: Duration) -> Self {
422 self.client = http_client(connect, total);
423 self
424 }
425
426 #[must_use]
434 pub fn from_env() -> Self {
435 let selection = select_mempool_endpoint_from_env();
436 log_mempool_selection_once(&selection);
437 Self::from_selection(selection)
438 }
439
440 #[must_use]
442 pub fn base_url(&self) -> &str {
443 &self.base
444 }
445
446 #[must_use]
449 pub fn selection(&self) -> &MempoolSelection {
450 &self.selection
451 }
452
453 pub async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError> {
457 let url = format!("{}/api/tx/{txid}", self.base);
458 let resp = self
459 .client
460 .get(&url)
461 .send()
462 .await
463 .map_err(|e| PaymentError::InvalidState(format!("mempool request failed: {e}")))?;
464 if resp.status() == reqwest::StatusCode::NOT_FOUND {
465 return Ok(false);
466 }
467 if !resp.status().is_success() {
468 return Err(PaymentError::InvalidState(format!(
469 "mempool API error: {} for {url}",
470 resp.status().as_u16()
471 )));
472 }
473 Ok(true)
474 }
475
476 async fn get_text(&self, url: &str) -> Result<String, PaymentError> {
479 let resp = self
480 .client
481 .get(url)
482 .send()
483 .await
484 .map_err(|e| PaymentError::InvalidState(format!("mempool request failed: {e}")))?;
485 let status = resp.status();
486 if !status.is_success() {
487 return Err(PaymentError::InvalidState(format!(
488 "mempool API error: {} for {url}",
489 status.as_u16()
490 )));
491 }
492 resp.text()
493 .await
494 .map_err(|e| PaymentError::InvalidState(format!("mempool body read failed: {e}")))
495 }
496
497 async fn post_text(&self, url: &str, body: &str) -> Result<String, PaymentError> {
502 let resp = self
503 .client
504 .post(url)
505 .header("Content-Type", "text/plain")
506 .body(body.to_string())
507 .send()
508 .await
509 .map_err(|e| PaymentError::InvalidState(format!("mempool broadcast failed: {e}")))?;
510 let status = resp.status();
511 let text = resp
512 .text()
513 .await
514 .map_err(|e| PaymentError::InvalidState(format!("mempool body read failed: {e}")))?;
515 if !status.is_success() {
516 return Err(PaymentError::InvalidState(format!(
517 "broadcast rejected ({}): {text}",
518 status.as_u16()
519 )));
520 }
521 Ok(text.trim().to_string())
522 }
523}
524
525#[derive(Debug, Deserialize, Default)]
529struct StatusWire {
530 #[serde(default)]
531 confirmed: bool,
532 #[serde(default)]
533 block_height: Option<u64>,
534}
535
536#[derive(Debug, Deserialize)]
538struct UtxoWire {
539 txid: String,
540 vout: u32,
541 #[serde(default)]
542 value: u64,
543 #[serde(default)]
544 status: StatusWire,
545}
546
547impl From<UtxoWire> for Utxo {
548 fn from(w: UtxoWire) -> Self {
549 Utxo {
550 txid: w.txid,
551 vout: w.vout,
552 value: w.value,
553 confirmed: w.status.confirmed,
554 block_height: w.status.block_height,
555 }
556 }
557}
558
559#[derive(Debug, Deserialize, Default)]
561struct TxOutWire {
562 #[serde(default)]
563 value: u64,
564 #[serde(default)]
565 scriptpubkey: Option<String>,
566 #[serde(default)]
567 scriptpubkey_address: Option<String>,
568}
569
570impl From<TxOutWire> for TxOut {
571 fn from(w: TxOutWire) -> Self {
572 TxOut {
573 value: w.value,
574 scriptpubkey: w.scriptpubkey,
575 scriptpubkey_address: w.scriptpubkey_address,
576 }
577 }
578}
579
580#[derive(Debug, Deserialize)]
583struct TxInWire {
584 #[serde(default)]
585 txid: String,
586 #[serde(default)]
587 vout: u32,
588}
589
590#[derive(Debug, Deserialize)]
592struct TxWire {
593 txid: String,
594 #[serde(default)]
595 vin: Vec<TxInWire>,
596 #[serde(default)]
597 vout: Vec<TxOutWire>,
598 #[serde(default)]
599 status: StatusWire,
600}
601
602impl From<TxWire> for TxInfo {
603 fn from(w: TxWire) -> Self {
604 TxInfo {
605 txid: w.txid,
606 vin: w
607 .vin
608 .into_iter()
609 .map(|i| TxIn {
610 txid: i.txid,
611 vout: i.vout,
612 })
613 .collect(),
614 vout: w.vout.into_iter().map(TxOut::from).collect(),
615 confirmed: w.status.confirmed,
616 block_height: w.status.block_height,
617 }
618 }
619}
620
621#[async_trait(?Send)]
622impl MempoolLookup for MempoolHttpClient {
623 async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
624 let url = format!("{}/api/address/{address}/utxo", self.base);
625 let body = self.get_text(&url).await?;
626 let wire: Vec<UtxoWire> = serde_json::from_str(&body)
627 .map_err(|e| PaymentError::InvalidState(format!("malformed utxo JSON: {e}")))?;
628 Ok(wire.into_iter().map(Utxo::from).collect())
629 }
630
631 async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
632 let url = format!("{}/api/tx/{txid}", self.base);
633 let body = self.get_text(&url).await?;
634 let wire: TxWire = serde_json::from_str(&body)
635 .map_err(|e| PaymentError::InvalidState(format!("malformed tx JSON: {e}")))?;
636 Ok(TxInfo::from(wire))
637 }
638}
639
640#[async_trait(?Send)]
641impl MempoolBroadcast for MempoolHttpClient {
642 async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
643 let url = format!("{}/api/tx", self.base);
644 self.post_text(&url, raw_hex).await
645 }
646}
647
648#[derive(Clone)]
676pub struct MempoolBlockAnchorer<M: MempoolLookup + MempoolBroadcast + Send + Sync> {
677 lookup: M,
678 storage: Option<std::sync::Arc<dyn solid_pod_rs::storage::Storage>>,
679}
680
681impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> MempoolBlockAnchorer<M> {
682 pub fn new(lookup: M) -> Self {
686 Self {
687 lookup,
688 storage: None,
689 }
690 }
691
692 pub fn with_storage(
696 lookup: M,
697 storage: std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
698 ) -> Self {
699 Self {
700 lookup,
701 storage: Some(storage),
702 }
703 }
704
705 pub fn lookup(&self) -> &M {
707 &self.lookup
708 }
709}
710
711const PENDING_ANCHOR_PREFIX: &str = "anchor pending: ";
715
716fn pending_anchor(detail: String) -> ProvenanceError {
718 ProvenanceError::Anchor(format!("{PENDING_ANCHOR_PREFIX}{detail}"))
719}
720
721#[cfg(any(feature = "git", test))]
724pub(crate) fn is_pending_anchor(e: &ProvenanceError) -> bool {
725 matches!(e, ProvenanceError::Anchor(m) if m.starts_with(PENDING_ANCHOR_PREFIX))
726}
727
728#[async_trait(?Send)]
729impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> BlockAnchorer for MempoolBlockAnchorer<M> {
730 async fn anchor(
740 &self,
741 ticker: &str,
742 state_hash: &str,
743 network: &str,
744 ) -> Result<BlockTrailAnchor, ProvenanceError> {
745 use crate::trail_store::{load_trail, save_trail};
746
747 let storage = self.storage.as_ref().ok_or_else(|| {
748 ProvenanceError::Anchor(
749 "anchor() requires storage; construct with MempoolBlockAnchorer::with_storage"
750 .into(),
751 )
752 })?;
753
754 let _guard = crate::trail_store::lock_path(&crate::trail_store::trail_path(ticker)).await;
756
757 settle_trail_intent(&self.lookup, storage, ticker).await?;
758
759 let mut stored = load_trail(storage, ticker)
761 .await
762 .map_err(|e| ProvenanceError::Anchor(format!("load trail {ticker}: {e}")))?
763 .ok_or_else(|| {
764 ProvenanceError::Anchor(format!("trail {ticker} not minted on this pod"))
765 })?;
766
767 if stored.network != network {
768 return Err(ProvenanceError::Anchor(format!(
769 "network mismatch: trail is {}, requested {network}",
770 stored.network
771 )));
772 }
773
774 let public = stored.to_public();
776 let update = anchor_state(
777 &public,
778 &stored.privkey,
779 state_hash,
780 DEFAULT_FEE_SATS,
781 &self.lookup,
782 )
783 .await
784 .map_err(|e| ProvenanceError::Anchor(format!("build anchoring tx: {e}")))?;
785
786 let expected = solid_pod_rs::bitcoin_tx::decode_tx_info(&update.tx.raw_hex)
789 .map_err(|e| ProvenanceError::Anchor(format!("decode anchoring tx: {e}")))?
790 .txid;
791 let mut appended = update.trail.clone();
792 appended.current_txid = expected.clone();
793 stored.merge_public(&appended);
794 stored.current_txid = expected.clone();
795 stored.current_vout = 0;
796
797 let intent_path = anchor_intent_path(ticker);
801 let body = serde_json::to_vec(&PendingAnchor {
802 txid: expected.clone(),
803 raw_hex: update.tx.raw_hex.clone(),
804 stored: stored.clone(),
805 })
806 .map_err(|e| ProvenanceError::Anchor(format!("serialise pending anchor: {e}")))?;
807 storage
808 .put(&intent_path, bytes::Bytes::from(body), "application/json")
809 .await
810 .map_err(|e| ProvenanceError::Anchor(format!("record pending anchor: {e}")))?;
811
812 let txid = self
814 .lookup
815 .broadcast_tx(&update.tx.raw_hex)
816 .await
817 .map_err(|e| pending_anchor(format!("broadcast anchoring tx: {e}")))?;
818 if txid != expected {
819 return Err(pending_anchor(format!(
820 "mempool returned txid {txid} for an anchor that is {expected}"
821 )));
822 }
823
824 match save_trail(storage, &stored).await {
828 Ok(()) => {
829 if let Err(e) = storage.delete(&intent_path).await {
830 tracing::warn!(%ticker, "anchor intent not cleared: {e}");
832 }
833 }
834 Err(e) => tracing::error!(
835 %ticker, %txid,
836 "anchor broadcast accepted but trail save failed; the next advance settles it: {e}"
837 ),
838 }
839
840 Ok(BlockTrailAnchor {
841 ticker: ticker.to_string(),
842 state_hash: state_hash.to_string(),
843 txid,
844 vout: 0,
845 address: update.address,
846 network: network.to_string(),
847 blockheight: None,
848 state_strings: appended.state_strings,
849 pubkey: Some(stored.pubkey_base),
850 })
851 }
852
853 async fn verify(&self, anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
854 Ok(verify_anchor_report(&self.lookup, anchor)
855 .await?
856 .is_some_and(|r| r.is_intact()))
857 }
858}
859
860impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> MempoolBlockAnchorer<M> {
861 pub async fn verify_report(
867 &self,
868 anchor: &BlockTrailAnchor,
869 ) -> Result<Option<TrailReport>, ProvenanceError> {
870 verify_anchor_report(&self.lookup, anchor).await
871 }
872}
873
874pub async fn verify_anchor_report(
890 lookup: &dyn MempoolLookup,
891 anchor: &BlockTrailAnchor,
892) -> Result<Option<TrailReport>, ProvenanceError> {
893 let Some(pubkey) = anchor.pubkey.as_deref() else {
897 return Ok(None);
898 };
899 if anchor.state_strings.is_empty() {
900 return Ok(None);
901 }
902
903 let derived = bt_address(pubkey, &anchor.state_strings, &anchor.network)
906 .map_err(|e| ProvenanceError::Anchor(format!("address re-derivation failed: {e}")))?;
907 if derived != anchor.address {
908 return Ok(None);
909 }
910
911 verify_anchor_chain(
914 pubkey,
915 &anchor.state_strings,
916 &anchor.txid,
917 anchor.vout,
918 lookup,
919 )
920 .await
921 .map(Some)
922 .map_err(|e| ProvenanceError::Anchor(format!("anchor chain walk failed: {e}")))
923}
924
925fn already_known(e: &PaymentError) -> bool {
933 let msg = e.to_string().to_lowercase().replace('-', " ");
934 msg.contains("already in mempool")
935 || msg.contains("already in block chain")
936 || msg.contains("already known")
937 || msg.contains("already in utxo set")
938 || msg.contains("outputs already in")
939}
940
941async fn settle_broadcast<M: MempoolLookup + MempoolBroadcast + ?Sized>(
953 lookup: &M,
954 txid: &str,
955 raw_hex: &str,
956 head: Option<(&str, u32)>,
957) -> Result<bool, ProvenanceError> {
958 let unresolved = |why: &dyn std::fmt::Display| {
959 ProvenanceError::Anchor(format!(
960 "a previous mark ({txid}) is unresolved, refusing to advance: {why}"
961 ))
962 };
963 let refusal = match lookup.broadcast_tx(raw_hex).await {
964 Ok(t) if t == txid => return Ok(true),
965 Ok(t) => {
966 return Err(ProvenanceError::Anchor(format!(
967 "mempool returned txid {t} for a mark that is {txid}"
968 )))
969 }
970 Err(e) if already_known(&e) => return Ok(true),
971 Err(e) if e.to_string().contains("broadcast rejected (4") => e,
972 Err(e) => return Err(unresolved(&e)),
973 };
974 let spent = solid_pod_rs::bitcoin_tx::decode_tx_info(raw_hex)
975 .map_err(|e| unresolved(&e))?
976 .vin
977 .into_iter()
978 .next()
979 .ok_or_else(|| unresolved(&"it spends nothing"))?;
980 if head.is_some_and(|(t, v)| (t, v) != (spent.txid.as_str(), spent.vout) && t != txid) {
983 return Ok(false);
984 }
985 let prev = lookup.tx(&spent.txid).await.map_err(|e| unresolved(&e))?;
988 let address = prev
989 .vout
990 .get(spent.vout as usize)
991 .and_then(|o| o.scriptpubkey_address.clone())
992 .ok_or_else(|| unresolved(&refusal))?;
993 let utxos = lookup
994 .address_utxos(&address)
995 .await
996 .map_err(|e| unresolved(&e))?;
997 if utxos
998 .iter()
999 .any(|u| u.txid == spent.txid && u.vout == spent.vout)
1000 {
1001 Ok(false)
1002 } else {
1003 Err(unresolved(&refusal))
1004 }
1005}
1006
1007fn verify_intent_tx(
1012 txid: &str,
1013 raw_hex: &str,
1014 head: Option<(&str, u32)>,
1015 behind: bool,
1016) -> Result<(), ProvenanceError> {
1017 let bad =
1018 |why: String| ProvenanceError::Anchor(format!("pending intent {txid} refused: {why}"));
1019 let info = solid_pod_rs::bitcoin_tx::decode_tx_info(raw_hex)
1020 .map_err(|e| bad(format!("undecodable transaction: {e}")))?;
1021 if info.txid != txid {
1022 return Err(bad(format!("its transaction is {}", info.txid)));
1023 }
1024 if let (true, Some((head_txid, head_vout))) = (behind, head) {
1025 let spends_head = info
1026 .vin
1027 .first()
1028 .is_some_and(|i| i.txid == head_txid && i.vout == head_vout);
1029 if !spends_head {
1030 return Err(bad("it does not spend the stored head".into()));
1031 }
1032 }
1033 Ok(())
1034}
1035
1036#[derive(Serialize, Deserialize)]
1040#[serde(rename_all = "camelCase")]
1041struct PendingMark {
1042 #[serde(flatten)]
1043 intent: crate::trail_store::GitmarkIntent,
1044 #[serde(default)]
1045 raw_hex: Option<String>,
1046}
1047
1048async fn load_pending_mark(
1050 storage: &std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
1051 name: &str,
1052) -> Result<Option<PendingMark>, ProvenanceError> {
1053 use solid_pod_rs::PodError;
1054 match storage
1055 .get(&crate::trail_store::gitmark_intent_path(name))
1056 .await
1057 {
1058 Ok((bytes, _)) => serde_json::from_slice(&bytes).map(Some).map_err(|e| {
1059 ProvenanceError::Anchor(format!("read pending mark: malformed git-mark intent: {e}"))
1060 }),
1061 Err(PodError::NotFound(_)) => Ok(None),
1062 Err(e) => Err(ProvenanceError::Anchor(format!("read pending mark: {e}"))),
1063 }
1064}
1065
1066async fn save_pending_mark(
1068 storage: &std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
1069 pending: &PendingMark,
1070) -> Result<(), String> {
1071 let body = serde_json::to_vec(pending).map_err(|e| e.to_string())?;
1072 storage
1073 .put(
1074 &crate::trail_store::gitmark_intent_path(&pending.intent.stored.name),
1075 bytes::Bytes::from(body),
1076 "application/json",
1077 )
1078 .await
1079 .map(|_| ())
1080 .map_err(|e| e.to_string())
1081}
1082
1083#[derive(Serialize, Deserialize)]
1086#[serde(rename_all = "camelCase")]
1087struct PendingAnchor {
1088 txid: String,
1089 raw_hex: String,
1090 stored: crate::trail_store::StoredTrail,
1091}
1092
1093fn anchor_intent_path(ticker: &str) -> String {
1096 format!("/.well-known/token/{}/intent.json", ticker.to_lowercase())
1097}
1098
1099pub(crate) async fn settle_trail_intent<M: MempoolLookup + MempoolBroadcast + ?Sized>(
1110 lookup: &M,
1111 storage: &std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
1112 ticker: &str,
1113) -> Result<(), ProvenanceError> {
1114 use crate::trail_store::{load_trail, save_trail};
1115 use solid_pod_rs::PodError;
1116 let err =
1117 |what: &str, e: &dyn std::fmt::Display| ProvenanceError::Anchor(format!("{what}: {e}"));
1118 let path = anchor_intent_path(ticker);
1119 let pending: PendingAnchor = match storage.get(&path).await {
1120 Ok((bytes, _)) => {
1121 serde_json::from_slice(&bytes).map_err(|e| err("malformed pending anchor", &e))?
1122 }
1123 Err(PodError::NotFound(_)) => return Ok(()),
1124 Err(e) => return Err(err("read pending anchor", &e)),
1125 };
1126 let stored = load_trail(storage, ticker)
1127 .await
1128 .map_err(|e| err("load trail", &e))?;
1129 verify_intent_tx(
1130 &pending.txid,
1131 &pending.raw_hex,
1132 stored
1133 .as_ref()
1134 .map(|t| (t.current_txid.as_str(), t.current_vout)),
1135 stored.as_ref().map_or(0, |t| t.states.len()) < pending.stored.states.len(),
1136 )?;
1137 let landed = lookup.tx(&pending.txid).await.is_ok()
1138 || settle_broadcast(
1139 lookup,
1140 &pending.txid,
1141 &pending.raw_hex,
1142 stored
1143 .as_ref()
1144 .map(|t| (t.current_txid.as_str(), t.current_vout)),
1145 )
1146 .await?;
1147 if landed && stored.map_or(0, |t| t.states.len()) < pending.stored.states.len() {
1148 save_trail(storage, &pending.stored)
1149 .await
1150 .map_err(|e| err("save recovered trail", &e))?;
1151 }
1152 match storage.delete(&path).await {
1153 Ok(()) | Err(PodError::NotFound(_)) => Ok(()),
1154 Err(e) => Err(err("clear pending anchor", &e)),
1155 }
1156}
1157
1158#[async_trait(?Send)]
1166pub trait MempoolTxPresence {
1167 async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError>;
1170}
1171
1172#[async_trait(?Send)]
1173impl MempoolTxPresence for MempoolHttpClient {
1174 async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError> {
1175 MempoolHttpClient::transaction_exists(self, txid).await
1176 }
1177}
1178
1179#[derive(Clone)]
1202pub struct GitmarkAnchorer<M: MempoolLookup + MempoolBroadcast + MempoolTxPresence + Send + Sync> {
1203 lookup: M,
1204 storage: Option<std::sync::Arc<dyn solid_pod_rs::storage::Storage>>,
1205}
1206
1207impl<M: MempoolLookup + MempoolBroadcast + MempoolTxPresence + Send + Sync> GitmarkAnchorer<M> {
1208 pub fn new(lookup: M) -> Self {
1211 Self {
1212 lookup,
1213 storage: None,
1214 }
1215 }
1216
1217 pub fn with_storage(
1219 lookup: M,
1220 storage: std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
1221 ) -> Self {
1222 Self {
1223 lookup,
1224 storage: Some(storage),
1225 }
1226 }
1227
1228 pub fn lookup(&self) -> &M {
1230 &self.lookup
1231 }
1232
1233 fn storage(
1234 &self,
1235 ) -> Result<&std::sync::Arc<dyn solid_pod_rs::storage::Storage>, ProvenanceError> {
1236 self.storage.as_ref().ok_or_else(|| {
1237 ProvenanceError::Anchor(
1238 "a git-mark trail needs storage; construct with GitmarkAnchorer::with_storage"
1239 .into(),
1240 )
1241 })
1242 }
1243
1244 async fn settle_intent(&self, name: &str) -> Result<(), ProvenanceError> {
1252 use crate::trail_store::{clear_gitmark_intent, load_gitmark_trail, save_gitmark_trail};
1253 let storage = self.storage()?;
1254 let err = |what: &str, e: PaymentError| ProvenanceError::Anchor(format!("{what}: {e}"));
1255 let Some(pending) = load_pending_mark(storage, name).await? else {
1256 return Ok(());
1257 };
1258 let intent = &pending.intent;
1259 let exists = self
1260 .lookup
1261 .transaction_exists(&intent.txid)
1262 .await
1263 .map_err(|e| {
1264 ProvenanceError::Anchor(format!(
1265 "a previous mark ({}) is unresolved, refusing to advance: {e}",
1266 intent.txid
1267 ))
1268 })?;
1269 let stored = load_gitmark_trail(storage, name)
1273 .await
1274 .map_err(|e| err("load trail", e))?;
1275 if let Some(raw_hex) = &pending.raw_hex {
1276 verify_intent_tx(
1277 &intent.txid,
1278 raw_hex,
1279 stored
1280 .as_ref()
1281 .and_then(|t| t.trail.txo.last())
1282 .map(|m| (m.txid.as_str(), m.vout)),
1283 stored.as_ref().map_or(0, |t| t.trail.txo.len()) < intent.stored.trail.txo.len(),
1284 )?;
1285 }
1286 let landed = if exists {
1287 true
1288 } else if let Some(raw_hex) = &pending.raw_hex {
1289 let head = stored
1290 .as_ref()
1291 .and_then(|t| t.trail.txo.last())
1292 .map(|m| (m.txid.as_str(), m.vout));
1293 settle_broadcast(&self.lookup, &intent.txid, raw_hex, head).await?
1294 } else {
1295 tracing::warn!(trail = %name, txid = %intent.txid, "pending mark has no raw transaction; dropped as not on-chain");
1296 false
1297 };
1298 if landed && stored.map_or(0, |t| t.trail.txo.len()) < intent.stored.trail.txo.len() {
1299 save_gitmark_trail(storage, &intent.stored)
1300 .await
1301 .map_err(|e| err("save recovered trail", e))?;
1302 }
1303 clear_gitmark_intent(storage, name)
1304 .await
1305 .map_err(|e| err("clear pending mark", e))
1306 }
1307
1308 async fn broadcast_and_save(
1315 &self,
1316 name: &str,
1317 privkey: String,
1318 update: GitmarkUpdate,
1319 network: &str,
1320 date_created: String,
1321 ) -> Result<BlockTrailAnchor, ProvenanceError> {
1322 use crate::trail_store::{
1323 clear_gitmark_intent, save_gitmark_trail, GitmarkIntent, StoredGitmarkTrail,
1324 };
1325 let storage = self.storage()?;
1326 let expected = solid_pod_rs::bitcoin_tx::decode_tx_info(&update.tx.raw_hex)
1327 .map_err(|e| ProvenanceError::Anchor(format!("decode mark: {e}")))?
1328 .txid;
1329 let mut trail = update.trail;
1330 if let Some(last) = trail.txo.last_mut() {
1331 last.txid = expected.clone();
1332 }
1333 let state_strings = trail
1334 .state_strings()
1335 .map_err(|e| ProvenanceError::Anchor(format!("trail states: {e}")))?;
1336 let pubkey = trail.pubkey_base.clone();
1337 let stored = StoredGitmarkTrail {
1338 name: name.to_string(),
1339 privkey,
1340 trail,
1341 date_created,
1342 };
1343 save_pending_mark(
1344 storage,
1345 &PendingMark {
1346 intent: GitmarkIntent {
1347 txid: expected.clone(),
1348 stored: stored.clone(),
1349 },
1350 raw_hex: Some(update.tx.raw_hex.clone()),
1351 },
1352 )
1353 .await
1354 .map_err(|e| ProvenanceError::Anchor(format!("record pending mark: {e}")))?;
1355
1356 let txid = self
1357 .lookup
1358 .broadcast_tx(&update.tx.raw_hex)
1359 .await
1360 .map_err(|e| pending_anchor(format!("broadcast mark: {e}")))?;
1361 if txid != expected {
1362 return Err(pending_anchor(format!(
1363 "mempool returned txid {txid} for a mark that is {expected}"
1364 )));
1365 }
1366 match save_gitmark_trail(storage, &stored).await {
1367 Ok(()) => {
1368 if let Err(e) = clear_gitmark_intent(storage, name).await {
1369 tracing::warn!(trail = %name, "git-mark intent not cleared: {e}");
1371 }
1372 }
1373 Err(e) => tracing::error!(
1374 trail = %name, txid = %txid,
1375 "git-mark broadcast accepted but trail save failed; the next advance settles it: {e}"
1376 ),
1377 }
1378 Ok(BlockTrailAnchor {
1379 ticker: name.to_string(),
1380 state_hash: update.txo.commit.clone().unwrap_or_default(),
1381 txid,
1382 vout: update.txo.vout,
1383 address: update.address,
1384 network: network.to_string(),
1385 blockheight: None,
1386 state_strings,
1387 pubkey,
1388 })
1389 }
1390
1391 pub async fn genesis(
1401 &self,
1402 name: &str,
1403 voucher: &TxoVoucher,
1404 commit: &str,
1405 network: &str,
1406 date_created: &str,
1407 ) -> Result<BlockTrailAnchor, ProvenanceError> {
1408 use crate::trail_store::{gitmark_trail_path, load_gitmark_trail};
1409 let storage = self.storage()?;
1410 let _guard = crate::trail_store::lock_path(&gitmark_trail_path(name)).await;
1411 self.settle_intent(name).await?;
1412 if load_gitmark_trail(storage, name)
1413 .await
1414 .map_err(|e| ProvenanceError::Anchor(format!("load trail {name}: {e}")))?
1415 .is_some()
1416 {
1417 return Err(ProvenanceError::Anchor(format!(
1418 "git-mark trail {name} already exists"
1419 )));
1420 }
1421 let update = gitmark_genesis(voucher, commit, network, DEFAULT_FEE_SATS, &self.lookup)
1422 .await
1423 .map_err(|e| ProvenanceError::Anchor(format!("build genesis mark: {e}")))?;
1424 self.broadcast_and_save(
1425 name,
1426 voucher.privkey.clone(),
1427 update,
1428 network,
1429 date_created.to_string(),
1430 )
1431 .await
1432 }
1433
1434 pub async fn verify_report(
1437 &self,
1438 anchor: &BlockTrailAnchor,
1439 ) -> Result<Option<TrailReport>, ProvenanceError> {
1440 verify_anchor_report(&self.lookup, anchor).await
1441 }
1442}
1443
1444#[async_trait(?Send)]
1445impl<M: MempoolLookup + MempoolBroadcast + MempoolTxPresence + Send + Sync> BlockAnchorer
1446 for GitmarkAnchorer<M>
1447{
1448 async fn anchor(
1456 &self,
1457 ticker: &str,
1458 state_hash: &str,
1459 network: &str,
1460 ) -> Result<BlockTrailAnchor, ProvenanceError> {
1461 use crate::trail_store::{gitmark_trail_path, load_gitmark_trail};
1462 let _guard = crate::trail_store::lock_path(&gitmark_trail_path(ticker)).await;
1463 self.settle_intent(ticker).await?;
1464 let stored = load_gitmark_trail(self.storage()?, ticker)
1465 .await
1466 .map_err(|e| ProvenanceError::Anchor(format!("load trail {ticker}: {e}")))?
1467 .ok_or_else(|| {
1468 ProvenanceError::Anchor(format!(
1469 "git-mark trail {ticker} has no genesis on this pod"
1470 ))
1471 })?;
1472 let trail_network = stored.network();
1473 if trail_network != network {
1474 return Err(ProvenanceError::Anchor(format!(
1475 "network mismatch: trail is {trail_network}, requested {network}"
1476 )));
1477 }
1478 let update = gitmark_advance(
1479 &stored.trail,
1480 &stored.privkey,
1481 state_hash,
1482 DEFAULT_FEE_SATS,
1483 &self.lookup,
1484 )
1485 .await
1486 .map_err(|e| ProvenanceError::Anchor(format!("build mark: {e}")))?;
1487 self.broadcast_and_save(ticker, stored.privkey, update, network, stored.date_created)
1488 .await
1489 }
1490
1491 async fn verify(&self, anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
1492 Ok(verify_anchor_report(&self.lookup, anchor)
1493 .await?
1494 .is_some_and(|r| r.is_intact()))
1495 }
1496}
1497
1498#[cfg(test)]
1503mod tests {
1504 use super::*;
1505
1506 const UTXO_JSON: &str = include_str!("../tests/fixtures/mempool/address_utxos.json");
1509 const TX_JSON: &str = include_str!("../tests/fixtures/mempool/tx.json");
1511 const EMPTY_UTXO_JSON: &str = "[]";
1513
1514 #[test]
1515 fn utxo_wire_flattens_status() {
1516 let wire: Vec<UtxoWire> = serde_json::from_str(UTXO_JSON).unwrap();
1517 let utxos: Vec<Utxo> = wire.into_iter().map(Utxo::from).collect();
1518 assert_eq!(utxos.len(), 1);
1519 assert_eq!(utxos[0].vout, 0);
1520 assert_eq!(utxos[0].value, 9700);
1521 assert!(
1522 utxos[0].confirmed,
1523 "status.confirmed must flatten onto Utxo"
1524 );
1525 assert_eq!(utxos[0].block_height, Some(42_000));
1526 }
1527
1528 #[test]
1529 fn empty_utxo_set_parses_to_empty_vec() {
1530 let wire: Vec<UtxoWire> = serde_json::from_str(EMPTY_UTXO_JSON).unwrap();
1531 assert!(wire.is_empty());
1532 }
1533
1534 #[test]
1535 fn tx_wire_flattens_outputs_and_status() {
1536 let wire: TxWire = serde_json::from_str(TX_JSON).unwrap();
1537 let tx = TxInfo::from(wire);
1538 assert_eq!(tx.vout.len(), 2);
1539 assert_eq!(tx.vout[0].value, 9700);
1540 assert_eq!(
1541 tx.vout[0].scriptpubkey.as_deref(),
1542 Some("5120aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899")
1543 );
1544 assert_eq!(
1545 tx.vout[0].scriptpubkey_address.as_deref(),
1546 Some("tb1pexampleaddress")
1547 );
1548 assert!(tx.confirmed);
1549 assert_eq!(tx.block_height, Some(42_000));
1550 }
1551
1552 #[test]
1553 fn from_env_defaults_to_testnet4() {
1554 let prev = std::env::var(MEMPOOL_URL_ENV).ok();
1556 std::env::remove_var(MEMPOOL_URL_ENV);
1557 let c = MempoolHttpClient::from_env();
1558 assert_eq!(c.base_url(), DEFAULT_MEMPOOL_URL);
1559 if let Some(v) = prev {
1560 std::env::set_var(MEMPOOL_URL_ENV, v);
1561 }
1562 }
1563
1564 #[test]
1565 fn new_trims_trailing_slash() {
1566 let c = MempoolHttpClient::new("https://mempool.space/testnet4/");
1567 assert_eq!(c.base_url(), "https://mempool.space/testnet4");
1568 }
1569
1570 #[ignore = "hits live mempool.space; opt-in only"]
1573 #[tokio::test]
1574 async fn live_address_utxos_smoke() {
1575 let c = MempoolHttpClient::from_env();
1576 let _ = c
1579 .address_utxos("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c")
1580 .await;
1581 }
1582
1583 use std::collections::HashMap;
1586
1587 #[derive(Clone, Default)]
1592 struct FixtureMempool {
1593 utxos: std::sync::Arc<std::sync::Mutex<HashMap<String, Vec<Utxo>>>>,
1594 txs: std::sync::Arc<std::sync::Mutex<HashMap<String, TxInfo>>>,
1595 broadcasts: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1596 }
1597 impl FixtureMempool {
1598 fn empty() -> Self {
1599 Self::default()
1600 }
1601 fn add_output(&self, txid: &str, vout: u32, spk_hex: &str) {
1603 let mut txs = self.txs.lock().unwrap();
1604 let tx = txs.entry(txid.to_string()).or_insert_with(|| TxInfo {
1605 txid: txid.to_string(),
1606 vin: vec![],
1607 vout: vec![],
1608 confirmed: true,
1609 block_height: Some(41_000),
1610 });
1611 while tx.vout.len() <= vout as usize {
1612 tx.vout.push(TxOut {
1613 value: 0,
1614 scriptpubkey: None,
1615 scriptpubkey_address: None,
1616 });
1617 }
1618 tx.vout[vout as usize].scriptpubkey = Some(spk_hex.to_string());
1619 }
1620 fn add_chain(&self, pubkey: &str, state_strings: &[String]) -> String {
1624 let outputs = solid_pod_rs::mrc20::bt_trail_outputs(pubkey, state_strings).unwrap();
1625 let mut prev = "ff".repeat(32);
1626 for (i, x) in outputs.iter().enumerate() {
1627 let txid = solid_pod_rs::mrc20::sha256_hex(&format!("{pubkey} mark {i}"));
1628 self.txs.lock().unwrap().insert(
1629 txid.clone(),
1630 TxInfo {
1631 txid: txid.clone(),
1632 vin: vec![TxIn {
1633 txid: prev,
1634 vout: 0,
1635 }],
1636 vout: vec![TxOut {
1637 value: 9_700,
1638 scriptpubkey: Some(format!("5120{}", hex::encode(x))),
1639 scriptpubkey_address: None,
1640 }],
1641 confirmed: true,
1642 block_height: Some(42_000 + i as u64),
1643 },
1644 );
1645 prev = txid;
1646 }
1647 prev
1648 }
1649 }
1650 #[async_trait(?Send)]
1651 impl MempoolLookup for FixtureMempool {
1652 async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
1653 Ok(self
1654 .utxos
1655 .lock()
1656 .unwrap()
1657 .get(address)
1658 .cloned()
1659 .unwrap_or_default())
1660 }
1661 async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
1662 self.txs
1663 .lock()
1664 .unwrap()
1665 .get(txid)
1666 .cloned()
1667 .ok_or_else(|| PaymentError::InvalidState(format!("tx {txid} not found")))
1668 }
1669 }
1670 #[async_trait(?Send)]
1671 impl MempoolBroadcast for FixtureMempool {
1672 async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
1673 let mut info = solid_pod_rs::bitcoin_tx::decode_tx_info(raw_hex)?;
1674 info.confirmed = true;
1675 info.block_height = Some(43_000);
1676 let txid = info.txid.clone();
1677 self.txs.lock().unwrap().insert(txid.clone(), info);
1678 self.broadcasts.lock().unwrap().push(raw_hex.to_string());
1679 Ok(txid)
1680 }
1681 }
1682
1683 #[async_trait(?Send)]
1684 impl MempoolTxPresence for FixtureMempool {
1685 async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError> {
1686 Ok(self.txs.lock().unwrap().contains_key(txid))
1687 }
1688 }
1689
1690 const ISSUER_PRIVKEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
1692 fn issuer_pubkey() -> String {
1693 let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
1694 hex::encode(sk.public_key().to_sec1_bytes())
1695 }
1696
1697 fn consistent_anchor(txid: &str) -> BlockTrailAnchor {
1701 let pubkey = issuer_pubkey();
1702 let state_strings = vec!["{\"seq\":0}".to_string(), "{\"seq\":1}".to_string()];
1703 let address = bt_address(&pubkey, &state_strings, "testnet4").unwrap();
1704 BlockTrailAnchor {
1705 ticker: "PROV".into(),
1706 state_hash: "ff".repeat(32),
1707 txid: txid.to_string(),
1708 vout: 0,
1709 address,
1710 network: "testnet4".into(),
1711 blockheight: Some(42_000),
1712 state_strings,
1713 pubkey: Some(pubkey),
1714 }
1715 }
1716
1717 fn anchored_chain() -> (FixtureMempool, BlockTrailAnchor) {
1720 let mempool = FixtureMempool::empty();
1721 let probe = consistent_anchor("");
1722 let head = mempool.add_chain(&issuer_pubkey(), &probe.state_strings);
1723 (mempool, consistent_anchor(&head))
1724 }
1725
1726 #[test]
1727 fn tx_wire_reads_inputs() {
1728 let wire: TxWire = serde_json::from_str(
1729 r#"{"txid":"aa","vin":[{"txid":"bb","vout":3,"prevout":{}},{"is_coinbase":true}],"vout":[],"status":{"confirmed":false}}"#,
1730 )
1731 .unwrap();
1732 let tx = TxInfo::from(wire);
1733 assert_eq!(tx.vin.len(), 2);
1734 assert_eq!((tx.vin[0].txid.as_str(), tx.vin[0].vout), ("bb", 3));
1735 assert!(!tx.confirmed);
1736 }
1737
1738 #[tokio::test]
1739 async fn block_anchorer_verify_true_when_every_link_is_on_chain() {
1740 let (mempool, anchor) = anchored_chain();
1741 let anchorer = MempoolBlockAnchorer::new(mempool);
1742 assert!(anchorer.verify(&anchor).await.unwrap());
1743 let report = anchorer.verify_report(&anchor).await.unwrap().unwrap();
1744 assert!(report.is_verified());
1745 assert_eq!(report.marks.len(), 2);
1746 }
1747
1748 #[tokio::test]
1749 async fn block_anchorer_verify_false_when_the_anchor_tx_is_absent() {
1750 let anchor = consistent_anchor(&"ab".repeat(32));
1751 let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
1752 assert!(
1753 !anchorer.verify(&anchor).await.unwrap(),
1754 "no transaction behind the anchor ⇒ verify false"
1755 );
1756 }
1757
1758 #[tokio::test]
1759 async fn block_anchorer_verify_false_when_an_earlier_mark_does_not_commit() {
1760 let (mempool, anchor) = anchored_chain();
1763 let genesis = solid_pod_rs::mrc20::sha256_hex(&format!("{} mark 0", issuer_pubkey()));
1764 mempool.txs.lock().unwrap().get_mut(&genesis).unwrap().vout[0].scriptpubkey =
1765 Some(format!("5120{}", "22".repeat(32)));
1766 let anchorer = MempoolBlockAnchorer::new(mempool);
1767 assert!(!anchorer.verify(&anchor).await.unwrap());
1768 let report = anchorer.verify_report(&anchor).await.unwrap().unwrap();
1769 assert_eq!(
1770 report.marks[0].status,
1771 solid_pod_rs::blocktrail::MarkStatus::WrongKey
1772 );
1773 }
1774
1775 #[tokio::test]
1776 async fn block_anchorer_verify_false_when_address_forged() {
1777 let (mempool, mut anchor) = anchored_chain();
1780 anchor.address = "tb1pforged000000000000000000000000000000".into();
1781 let anchorer = MempoolBlockAnchorer::new(mempool);
1782 assert!(
1783 !anchorer.verify(&anchor).await.unwrap(),
1784 "forged address must not verify even with a real chain behind it"
1785 );
1786 assert!(anchorer.verify_report(&anchor).await.unwrap().is_none());
1787 }
1788
1789 #[tokio::test]
1790 async fn block_anchorer_verify_false_without_pubkey() {
1791 let (mempool, mut anchor) = anchored_chain();
1793 anchor.pubkey = None;
1794 let anchorer = MempoolBlockAnchorer::new(mempool);
1795 assert!(!anchorer.verify(&anchor).await.unwrap());
1796 }
1797
1798 #[tokio::test]
1799 async fn block_anchorer_anchor_requires_storage() {
1800 let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
1803 let err = anchorer
1804 .anchor("PROV", "deadbeef", "testnet4")
1805 .await
1806 .unwrap_err();
1807 match err {
1808 ProvenanceError::Anchor(m) => assert!(m.contains("with_storage")),
1809 other => panic!("expected Anchor(requires storage), got {other:?}"),
1810 }
1811 }
1812
1813 use crate::trail_store::{load_gitmark_trail, load_trail, save_trail, StoredTrail};
1816 use solid_pod_rs::bitcoin_tx::mint_token;
1817 use solid_pod_rs::storage::memory::MemoryBackend;
1818 use solid_pod_rs::storage::Storage;
1819
1820 fn issuer_voucher(mempool: &FixtureMempool, txid: &str) -> TxoVoucher {
1822 let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
1823 let compressed = sk.public_key().to_sec1_bytes();
1824 mempool.add_output(txid, 0, &format!("5120{}", hex::encode(&compressed[1..])));
1825 TxoVoucher {
1826 txid: txid.to_string(),
1827 vout: 0,
1828 amount: 100_000,
1829 privkey: ISSUER_PRIVKEY.to_string(),
1830 }
1831 }
1832
1833 async fn mint_and_store(ticker: &str) -> (std::sync::Arc<dyn Storage>, FixtureMempool, String) {
1836 let mempool = FixtureMempool::empty();
1837 let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
1838 let voucher = issuer_voucher(&mempool, &"11".repeat(32));
1839 let mint = mint_token(ticker, None, 1_000, &voucher, "testnet4", 300, &mempool)
1840 .await
1841 .unwrap();
1842 let mint_txid = mempool.broadcast_tx(&mint.tx.raw_hex).await.unwrap();
1843
1844 let stored = StoredTrail {
1846 ticker: mint.trail.ticker.clone(),
1847 name: mint.trail.name.clone(),
1848 supply: mint.trail.supply,
1849 privkey: ISSUER_PRIVKEY.to_string(),
1850 pubkey_base: mint.trail.pubkey_base.clone(),
1851 states: mint.trail.states.clone(),
1852 state_strings: mint.trail.state_strings.clone(),
1853 current_txid: mint_txid,
1854 current_vout: 0,
1855 current_amount: mint.trail.current_amount,
1856 network: mint.trail.network.clone(),
1857 date_created: "2026-06-13T00:00:00Z".into(),
1858 };
1859 save_trail(&storage, &stored).await.unwrap();
1860 (storage, mempool, ticker.to_string())
1861 }
1862
1863 #[tokio::test]
1864 async fn block_anchorer_anchor_round_trip_and_self_verifies() {
1865 let (storage, mempool, ticker) = mint_and_store("ANCH").await;
1866 let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), storage.clone());
1867
1868 let commit_sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9001122334";
1870 let anchor = anchorer
1871 .anchor(&ticker, commit_sha, "testnet4")
1872 .await
1873 .expect("anchor() must build + broadcast + persist");
1874
1875 assert_eq!(anchor.ticker, "ANCH");
1876 assert_eq!(anchor.state_hash, commit_sha);
1877 assert_eq!(anchor.vout, 0);
1878 assert!(anchor.blockheight.is_none());
1879 assert_eq!(anchor.network, "testnet4");
1880 assert!(anchor.pubkey.is_some());
1881 assert_eq!(anchor.state_strings.len(), 2);
1883 let derived = bt_address(
1885 anchor.pubkey.as_deref().unwrap(),
1886 &anchor.state_strings,
1887 "testnet4",
1888 )
1889 .unwrap();
1890 assert_eq!(anchor.address, derived);
1891
1892 let reloaded = load_trail(&storage, "ANCH").await.unwrap().unwrap();
1895 assert_eq!(reloaded.states.len(), 2);
1896 assert_eq!(reloaded.current_txid, anchor.txid);
1897 assert_eq!(reloaded.states[1].anchor.as_deref(), Some(commit_sha));
1898 assert_eq!(reloaded.states[1].ops[0].op, "urn:mono:op:anchor");
1899
1900 assert!(
1902 anchorer.verify(&anchor).await.unwrap(),
1903 "the anchor we just produced must verify, every link"
1904 );
1905
1906 let second = anchorer
1909 .anchor(&ticker, &"b".repeat(40), "testnet4")
1910 .await
1911 .unwrap();
1912 assert_eq!(second.state_strings.len(), 3);
1913 assert!(anchorer.verify(&second).await.unwrap());
1914 assert!(anchorer.verify(&anchor).await.unwrap());
1915 }
1916
1917 #[tokio::test]
1918 async fn block_anchorer_anchor_rejects_unminted_ticker() {
1919 let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
1920 let anchorer = MempoolBlockAnchorer::with_storage(FixtureMempool::empty(), storage);
1921 let err = anchorer
1922 .anchor("GHOST", "deadbeef", "testnet4")
1923 .await
1924 .unwrap_err();
1925 match err {
1926 ProvenanceError::Anchor(m) => assert!(m.contains("not minted")),
1927 other => panic!("expected not-minted error, got {other:?}"),
1928 }
1929 }
1930
1931 const COMMITS: [&str; 3] = [
1934 "0123456789abcdef0123456789abcdef01234567",
1935 "cf97baba489e88c1ffbe6758c0fe8c18ff83d17d",
1936 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
1937 ];
1938
1939 #[tokio::test]
1940 async fn gitmark_anchorer_genesis_anchor_verify() {
1941 let mempool = FixtureMempool::empty();
1942 let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
1943 let anchorer = GitmarkAnchorer::with_storage(mempool.clone(), storage.clone());
1944 let voucher = issuer_voucher(&mempool, &"33".repeat(32));
1945
1946 let genesis = anchorer
1947 .genesis(
1948 "prov",
1949 &voucher,
1950 COMMITS[0],
1951 "testnet4",
1952 "2026-10-02T00:00:00Z",
1953 )
1954 .await
1955 .unwrap();
1956 assert_eq!(genesis.state_strings, vec![COMMITS[0].to_string()]);
1957 assert_eq!(genesis.pubkey.as_deref(), Some(issuer_pubkey().as_str()));
1958 assert!(anchorer.verify(&genesis).await.unwrap());
1959 let again = anchorer
1961 .genesis("prov", &voucher, COMMITS[0], "testnet4", "")
1962 .await
1963 .unwrap_err();
1964 assert!(again.to_string().contains("already exists"));
1965
1966 let mut last = genesis.clone();
1967 for commit in &COMMITS[1..] {
1968 last = anchorer.anchor("prov", commit, "testnet4").await.unwrap();
1969 assert_eq!(last.state_hash, *commit);
1970 }
1971 assert_eq!(
1972 last.state_strings,
1973 COMMITS.iter().map(|c| c.to_string()).collect::<Vec<_>>()
1974 );
1975 let report = anchorer.verify_report(&last).await.unwrap().unwrap();
1976 assert!(report.is_verified(), "{report:?}");
1977 assert_eq!(report.marks.len(), 3);
1978 assert!(anchorer.verify(&genesis).await.unwrap());
1980
1981 let stored = load_gitmark_trail(&storage, "prov").await.unwrap().unwrap();
1984 assert_eq!(stored.trail.txo.len(), 3);
1985 assert_eq!(stored.trail.txo[2].txid, last.txid);
1986 let report = solid_pod_rs::blocktrail::verify_blocktrail(&stored.trail, &mempool).await;
1987 assert!(report.is_verified(), "{report:?}");
1988 let (published, _) = storage
1989 .get(&crate::trail_store::gitmark_blocktrails_path("prov"))
1990 .await
1991 .unwrap();
1992 let published: solid_pod_rs::blocktrail::Blocktrail =
1993 serde_json::from_slice(&published).unwrap();
1994 assert_eq!(published, stored.trail);
1995 }
1996
1997 #[tokio::test]
1998 async fn gitmark_anchorer_refusals() {
1999 let mempool = FixtureMempool::empty();
2000 let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
2001 let anchorer = GitmarkAnchorer::with_storage(mempool.clone(), storage);
2002 let err = anchorer
2003 .anchor("prov", COMMITS[0], "testnet4")
2004 .await
2005 .unwrap_err();
2006 assert!(err.to_string().contains("no genesis"), "{err}");
2007
2008 let voucher = issuer_voucher(&mempool, &"34".repeat(32));
2009 anchorer
2010 .genesis("prov", &voucher, COMMITS[0], "testnet4", "")
2011 .await
2012 .unwrap();
2013 let err = anchorer
2014 .anchor("prov", COMMITS[1], "mainnet")
2015 .await
2016 .unwrap_err();
2017 assert!(err.to_string().contains("network mismatch"), "{err}");
2018 let err = anchorer
2019 .anchor("prov", "not-a-commit", "testnet4")
2020 .await
2021 .unwrap_err();
2022 assert!(err.to_string().contains("commit hash"), "{err}");
2023
2024 let verify_only = GitmarkAnchorer::new(mempool);
2025 let err = verify_only
2026 .anchor("prov", COMMITS[1], "testnet4")
2027 .await
2028 .unwrap_err();
2029 assert!(err.to_string().contains("with_storage"), "{err}");
2030 }
2031
2032 #[derive(Clone)]
2037 struct RacyMempool {
2038 inner: FixtureMempool,
2039 spent: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<(String, u32)>>>,
2040 drop_next_broadcast: std::sync::Arc<std::sync::atomic::AtomicBool>,
2041 index_lag: std::sync::Arc<std::sync::atomic::AtomicBool>,
2044 reject_next: std::sync::Arc<std::sync::Mutex<Option<String>>>,
2046 }
2047 impl RacyMempool {
2048 fn new(inner: FixtureMempool) -> Self {
2049 Self {
2050 inner,
2051 spent: Default::default(),
2052 drop_next_broadcast: Default::default(),
2053 index_lag: Default::default(),
2054 reject_next: Default::default(),
2055 }
2056 }
2057 fn reject_next(&self, msg: &str) {
2058 *self.reject_next.lock().unwrap() = Some(msg.into());
2059 }
2060 fn show_unspent(&self, txid: &str, vout: u32) {
2063 let address = format!("tb1q{txid}{vout}");
2064 self.inner.txs.lock().unwrap().get_mut(txid).unwrap().vout[vout as usize]
2065 .scriptpubkey_address = Some(address.clone());
2066 self.inner.utxos.lock().unwrap().insert(
2067 address,
2068 vec![Utxo {
2069 txid: txid.into(),
2070 vout,
2071 value: 9_700,
2072 confirmed: true,
2073 block_height: Some(1),
2074 }],
2075 );
2076 }
2077 }
2078 #[async_trait(?Send)]
2079 impl MempoolLookup for RacyMempool {
2080 async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
2081 tokio::task::yield_now().await;
2082 self.inner.address_utxos(address).await
2083 }
2084 async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
2085 tokio::task::yield_now().await;
2086 self.inner.tx(txid).await
2087 }
2088 }
2089 #[async_trait(?Send)]
2090 impl MempoolBroadcast for RacyMempool {
2091 async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
2092 tokio::task::yield_now().await;
2093 if self
2094 .drop_next_broadcast
2095 .swap(false, std::sync::atomic::Ordering::SeqCst)
2096 {
2097 return Err(PaymentError::InvalidState("connection reset".into()));
2098 }
2099 let info = solid_pod_rs::bitcoin_tx::decode_tx_info(raw_hex)?;
2100 if let Some(msg) = self.reject_next.lock().unwrap().take() {
2101 return Err(PaymentError::InvalidState(msg));
2102 }
2103 if self.inner.txs.lock().unwrap().contains_key(&info.txid) {
2105 return Err(PaymentError::InvalidState(
2106 "broadcast rejected (400): txn-already-in-mempool".into(),
2107 ));
2108 }
2109 {
2110 let mut spent = self.spent.lock().unwrap();
2111 if info
2112 .vin
2113 .iter()
2114 .any(|i| spent.contains(&(i.txid.clone(), i.vout)))
2115 {
2116 return Err(PaymentError::InvalidState("txn-mempool-conflict".into()));
2117 }
2118 for i in &info.vin {
2119 spent.insert((i.txid.clone(), i.vout));
2120 }
2121 }
2122 self.inner.broadcast_tx(raw_hex).await
2123 }
2124 }
2125
2126 #[async_trait(?Send)]
2127 impl MempoolTxPresence for RacyMempool {
2128 async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError> {
2129 tokio::task::yield_now().await;
2130 if self.index_lag.load(std::sync::atomic::Ordering::SeqCst) {
2131 return Ok(false);
2132 }
2133 self.inner.transaction_exists(txid).await
2134 }
2135 }
2136
2137 async fn racy_trail() -> (
2140 RacyMempool,
2141 std::sync::Arc<crate::trail_store::fault_storage::FaultStorage>,
2142 GitmarkAnchorer<RacyMempool>,
2143 GitmarkAnchorer<RacyMempool>,
2144 ) {
2145 let fixture = FixtureMempool::empty();
2146 let voucher = issuer_voucher(&fixture, &"35".repeat(32));
2147 let mempool = RacyMempool::new(fixture);
2148 let inner: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
2149 let storage = crate::trail_store::fault_storage::FaultStorage::new(inner, true);
2150 let a = GitmarkAnchorer::with_storage(mempool.clone(), storage.clone());
2151 let b = GitmarkAnchorer::with_storage(mempool.clone(), storage.clone());
2152 a.genesis("prov", &voucher, COMMITS[0], "testnet4", "")
2153 .await
2154 .unwrap();
2155 (mempool, storage, a, b)
2156 }
2157
2158 #[tokio::test]
2159 async fn concurrent_anchors_build_on_each_others_head() {
2160 let (mempool, storage, a, b) = racy_trail().await;
2161 let (ra, rb) = tokio::join!(
2162 a.anchor("prov", COMMITS[1], "testnet4"),
2163 b.anchor("prov", COMMITS[2], "testnet4"),
2164 );
2165 let (ra, rb) = (ra.unwrap(), rb.unwrap());
2166 let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2167 let stored = load_gitmark_trail(&dyn_storage, "prov")
2168 .await
2169 .unwrap()
2170 .unwrap();
2171 assert_eq!(stored.trail.txo.len(), 3, "both anchors must land");
2172 let heads = [ra.txid.clone(), rb.txid.clone()];
2173 assert_eq!(
2174 heads
2175 .iter()
2176 .filter(|t| **t == stored.trail.txo[2].txid)
2177 .count(),
2178 1
2179 );
2180 assert!(heads.contains(&stored.trail.txo[1].txid));
2181 let report = solid_pod_rs::blocktrail::verify_blocktrail(&stored.trail, &mempool).await;
2182 assert!(report.is_verified(), "{report:?}");
2183 }
2184
2185 #[tokio::test]
2186 async fn broadcast_then_failed_save_is_ok_and_recovered_by_the_next_anchor() {
2187 let (mempool, storage, a, _b) = racy_trail().await;
2188 storage.fail_put(&crate::trail_store::gitmark_trail_path("prov"));
2189 let landed = a
2190 .anchor("prov", COMMITS[1], "testnet4")
2191 .await
2192 .expect("the tx is on-chain: a failed save must not read as a failed anchor");
2193 storage.heal();
2194 let next = a.anchor("prov", COMMITS[2], "testnet4").await.unwrap();
2195 let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2196 let stored = load_gitmark_trail(&dyn_storage, "prov")
2197 .await
2198 .unwrap()
2199 .unwrap();
2200 assert_eq!(stored.trail.txo.len(), 3);
2201 assert_eq!(stored.trail.txo[1].txid, landed.txid);
2202 assert_eq!(stored.trail.txo[2].txid, next.txid);
2203 let report = solid_pod_rs::blocktrail::verify_blocktrail(&stored.trail, &mempool).await;
2204 assert!(report.is_verified(), "{report:?}");
2205 }
2206
2207 #[test]
2208 fn already_known_reads_only_node_statements_about_the_same_tx() {
2209 let e = |m: &str| PaymentError::InvalidState(m.into());
2210 assert!(already_known(&e(
2211 "broadcast rejected (400): txn-already-in-mempool"
2212 )));
2213 assert!(already_known(&e(
2214 "broadcast rejected (400): Transaction already in block chain"
2215 )));
2216 assert!(already_known(&e(
2217 "broadcast rejected (400): txn-already-known"
2218 )));
2219 assert!(!already_known(&e(
2220 "broadcast rejected (400): txn-mempool-conflict"
2221 )));
2222 assert!(!already_known(&e(
2223 "mempool broadcast failed: connection reset"
2224 )));
2225 }
2226
2227 #[tokio::test]
2228 async fn dropped_broadcast_is_resent_by_the_next_anchor() {
2229 let (mempool, storage, a, _b) = racy_trail().await;
2230 mempool
2231 .drop_next_broadcast
2232 .store(true, std::sync::atomic::Ordering::SeqCst);
2233 let lost = a.anchor("prov", COMMITS[1], "testnet4").await;
2234 assert!(
2235 is_pending_anchor(&lost.unwrap_err()),
2236 "a recorded but unconfirmed broadcast is pending, not failed"
2237 );
2238 let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2240 assert!(
2241 crate::trail_store::load_gitmark_intent(&dyn_storage, "prov")
2242 .await
2243 .unwrap()
2244 .is_some()
2245 );
2246 let next = a.anchor("prov", COMMITS[2], "testnet4").await.unwrap();
2249 let stored = load_gitmark_trail(&dyn_storage, "prov")
2250 .await
2251 .unwrap()
2252 .unwrap();
2253 assert_eq!(stored.trail.txo.len(), 3, "the re-sent mark landed");
2254 assert_eq!(stored.trail.txo[2].txid, next.txid);
2255 assert!(
2256 crate::trail_store::load_gitmark_intent(&dyn_storage, "prov")
2257 .await
2258 .unwrap()
2259 .is_none()
2260 );
2261 let report = solid_pod_rs::blocktrail::verify_blocktrail(&stored.trail, &mempool).await;
2262 assert!(report.is_verified(), "{report:?}");
2263 }
2264
2265 #[tokio::test]
2266 async fn unresolved_resend_keeps_the_intent_and_refuses_to_advance() {
2267 let (mempool, storage, a, _b) = racy_trail().await;
2268 let drop_next = || {
2269 mempool
2270 .drop_next_broadcast
2271 .store(true, std::sync::atomic::Ordering::SeqCst)
2272 };
2273 drop_next();
2274 a.anchor("prov", COMMITS[1], "testnet4").await.unwrap_err();
2275 drop_next();
2277 a.anchor("prov", COMMITS[2], "testnet4").await.unwrap_err();
2278 let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2279 assert!(
2280 crate::trail_store::load_gitmark_intent(&dyn_storage, "prov")
2281 .await
2282 .unwrap()
2283 .is_some(),
2284 "an unresolved mark must keep its intent"
2285 );
2286 let stored = load_gitmark_trail(&dyn_storage, "prov")
2287 .await
2288 .unwrap()
2289 .unwrap();
2290 assert_eq!(stored.trail.txo.len(), 1);
2291 }
2292
2293 #[tokio::test]
2294 async fn rejected_resend_with_an_unspent_head_drops_the_intent() {
2295 let (mempool, storage, a, _b) = racy_trail().await;
2296 let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2297 let head = load_gitmark_trail(&dyn_storage, "prov")
2298 .await
2299 .unwrap()
2300 .unwrap()
2301 .trail
2302 .txo[0]
2303 .txid
2304 .clone();
2305 mempool
2306 .drop_next_broadcast
2307 .store(true, std::sync::atomic::Ordering::SeqCst);
2308 a.anchor("prov", COMMITS[1], "testnet4").await.unwrap_err();
2309 mempool.reject_next("broadcast rejected (400): min relay fee not met");
2311 mempool.show_unspent(&head, 0);
2312 a.anchor("prov", COMMITS[2], "testnet4")
2313 .await
2314 .expect("a refused mark must not wedge the trail");
2315 let stored = load_gitmark_trail(&dyn_storage, "prov")
2316 .await
2317 .unwrap()
2318 .unwrap();
2319 assert_eq!(stored.trail.txo.len(), 2, "the refused mark never landed");
2320 assert!(
2321 crate::trail_store::load_gitmark_intent(&dyn_storage, "prov")
2322 .await
2323 .unwrap()
2324 .is_none()
2325 );
2326 }
2327
2328 #[tokio::test]
2329 async fn rejected_resend_without_proof_of_an_unspent_head_keeps_refusing() {
2330 let (mempool, storage, a, _b) = racy_trail().await;
2331 mempool
2332 .drop_next_broadcast
2333 .store(true, std::sync::atomic::Ordering::SeqCst);
2334 a.anchor("prov", COMMITS[1], "testnet4").await.unwrap_err();
2335 mempool.reject_next("broadcast rejected (400): bad-txns-inputs-missingorspent");
2337 a.anchor("prov", COMMITS[2], "testnet4").await.unwrap_err();
2338 let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2339 assert!(
2340 crate::trail_store::load_gitmark_intent(&dyn_storage, "prov")
2341 .await
2342 .unwrap()
2343 .is_some()
2344 );
2345 }
2346
2347 #[tokio::test]
2348 async fn block_anchorer_rejected_resend_with_an_unspent_head_drops_the_intent() {
2349 let (inner, fixture, ticker) = mint_and_store("DRP").await;
2350 let mempool = RacyMempool::new(fixture);
2351 let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), inner.clone());
2352 let head = load_trail(&inner, &ticker)
2353 .await
2354 .unwrap()
2355 .unwrap()
2356 .current_txid;
2357 mempool
2358 .drop_next_broadcast
2359 .store(true, std::sync::atomic::Ordering::SeqCst);
2360 anchorer
2361 .anchor(&ticker, &"a".repeat(40), "testnet4")
2362 .await
2363 .unwrap_err();
2364 mempool.reject_next("broadcast rejected (400): min relay fee not met");
2365 mempool.show_unspent(&head, 0);
2366 anchorer
2367 .anchor(&ticker, &"b".repeat(40), "testnet4")
2368 .await
2369 .expect("a refused anchor must not wedge the trail");
2370 let stored = load_trail(&inner, &ticker).await.unwrap().unwrap();
2371 assert_eq!(stored.states.len(), 2);
2372 assert!(!inner.exists(&anchor_intent_path(&ticker)).await.unwrap());
2373 }
2374
2375 #[tokio::test]
2376 async fn block_anchorer_refuses_an_intent_whose_txid_is_not_its_transaction() {
2377 let (inner, fixture, ticker) = mint_and_store("TMP").await;
2378 let mempool = RacyMempool::new(fixture);
2379 let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), inner.clone());
2380 mempool
2381 .drop_next_broadcast
2382 .store(true, std::sync::atomic::Ordering::SeqCst);
2383 anchorer
2384 .anchor(&ticker, &"a".repeat(40), "testnet4")
2385 .await
2386 .unwrap_err();
2387 let path = anchor_intent_path(&ticker);
2392 let head = load_trail(&inner, &ticker)
2393 .await
2394 .unwrap()
2395 .unwrap()
2396 .current_txid;
2397 let (bytes, _) = inner.get(&path).await.unwrap();
2398 let mut v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
2399 v["txid"] = serde_json::json!(head);
2400 inner
2401 .put(
2402 &path,
2403 bytes::Bytes::from(serde_json::to_vec(&v).unwrap()),
2404 "application/json",
2405 )
2406 .await
2407 .unwrap();
2408 let before = load_trail(&inner, &ticker).await.unwrap().unwrap();
2409 let err = anchorer
2410 .anchor(&ticker, &"b".repeat(40), "testnet4")
2411 .await
2412 .unwrap_err();
2413 assert!(err.to_string().contains("refused"), "{err}");
2414 let after = load_trail(&inner, &ticker).await.unwrap().unwrap();
2415 assert_eq!(after.states.len(), before.states.len());
2416 assert!(inner.exists(&path).await.unwrap(), "the intent is kept");
2417 }
2418
2419 #[tokio::test]
2420 async fn accepted_but_unindexed_mark_is_not_orphaned_by_the_next_anchor() {
2421 let (mempool, storage, a, _b) = racy_trail().await;
2422 storage.fail_put(&crate::trail_store::gitmark_trail_path("prov"));
2424 let landed = a.anchor("prov", COMMITS[1], "testnet4").await.unwrap();
2425 storage.heal();
2426 mempool
2428 .index_lag
2429 .store(true, std::sync::atomic::Ordering::SeqCst);
2430 let next = a
2431 .anchor("prov", COMMITS[2], "testnet4")
2432 .await
2433 .expect("the next advance must build on the accepted mark, not on the old head");
2434 let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2435 let stored = load_gitmark_trail(&dyn_storage, "prov")
2436 .await
2437 .unwrap()
2438 .unwrap();
2439 assert_eq!(stored.trail.txo.len(), 3);
2440 assert_eq!(stored.trail.txo[1].txid, landed.txid);
2441 assert_eq!(stored.trail.txo[2].txid, next.txid);
2442 let report = solid_pod_rs::blocktrail::verify_blocktrail(&stored.trail, &mempool).await;
2443 assert!(report.is_verified(), "{report:?}");
2444 }
2445
2446 #[tokio::test]
2447 async fn block_anchorer_accepted_broadcast_with_failed_save_is_not_a_failed_anchor() {
2448 use crate::trail_store::fault_storage::FaultStorage;
2449 let (inner, mempool, ticker) = mint_and_store("FLT").await;
2450 let storage = FaultStorage::new(inner, false);
2451 let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), storage.clone());
2452 storage.fail_put(&crate::trail_store::trail_path(&ticker));
2453 let first = anchorer
2454 .anchor(&ticker, &"a".repeat(40), "testnet4")
2455 .await
2456 .expect("the tx is on-chain: a failed save must not read as a failed anchor");
2457 storage.heal();
2458 let second = anchorer
2460 .anchor(&ticker, &"b".repeat(40), "testnet4")
2461 .await
2462 .unwrap();
2463 let dyn_storage: std::sync::Arc<dyn Storage> = storage.clone();
2464 let stored = load_trail(&dyn_storage, &ticker).await.unwrap().unwrap();
2465 assert_eq!(stored.states.len(), 3);
2466 assert_eq!(stored.current_txid, second.txid);
2467 let second_tx = mempool
2468 .txs
2469 .lock()
2470 .unwrap()
2471 .get(&second.txid)
2472 .cloned()
2473 .unwrap();
2474 assert_eq!(
2475 second_tx.vin[0].txid, first.txid,
2476 "the second anchor must spend the first, not the old head"
2477 );
2478 }
2479
2480 #[tokio::test]
2481 async fn http_client_presence_check_reaches_the_inherent_method() {
2482 let client = MempoolHttpClient::new("http://127.0.0.1:1");
2485 let out =
2486 <MempoolHttpClient as MempoolTxPresence>::transaction_exists(&client, &"ab".repeat(32))
2487 .await;
2488 assert!(out.is_err(), "{out:?}");
2489 }
2490}