use async_trait::async_trait;
use serde::Deserialize;
use solid_pod_rs::bitcoin_tx::{anchor_state, MempoolBroadcast};
use solid_pod_rs::mrc20::{bt_address, MempoolLookup, TxInfo, TxOut, Utxo};
use solid_pod_rs::payments::PaymentError;
use solid_pod_rs::provenance::{BlockAnchorer, BlockTrailAnchor, ProvenanceError};
pub const MEMPOOL_URL_ENV: &str = "JSS_PAY_MEMPOOL_URL";
pub const DEFAULT_MEMPOOL_URL: &str = "https://mempool.space/testnet4";
#[derive(Debug, Clone)]
pub struct MempoolHttpClient {
client: reqwest::Client,
base: String,
}
impl MempoolHttpClient {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
let base = base_url.into().trim_end_matches('/').to_string();
Self {
client: reqwest::Client::new(),
base,
}
}
#[must_use]
pub fn from_env() -> Self {
let base = std::env::var(MEMPOOL_URL_ENV)
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| DEFAULT_MEMPOOL_URL.to_string());
Self::new(base)
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base
}
async fn get_text(&self, url: &str) -> Result<String, PaymentError> {
let resp = self
.client
.get(url)
.send()
.await
.map_err(|e| PaymentError::InvalidState(format!("mempool request failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
return Err(PaymentError::InvalidState(format!(
"mempool API error: {} for {url}",
status.as_u16()
)));
}
resp.text()
.await
.map_err(|e| PaymentError::InvalidState(format!("mempool body read failed: {e}")))
}
async fn post_text(&self, url: &str, body: &str) -> Result<String, PaymentError> {
let resp = self
.client
.post(url)
.header("Content-Type", "text/plain")
.body(body.to_string())
.send()
.await
.map_err(|e| PaymentError::InvalidState(format!("mempool broadcast failed: {e}")))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| PaymentError::InvalidState(format!("mempool body read failed: {e}")))?;
if !status.is_success() {
return Err(PaymentError::InvalidState(format!(
"broadcast rejected ({}): {text}",
status.as_u16()
)));
}
Ok(text.trim().to_string())
}
}
#[derive(Debug, Deserialize, Default)]
struct StatusWire {
#[serde(default)]
confirmed: bool,
#[serde(default)]
block_height: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct UtxoWire {
txid: String,
vout: u32,
#[serde(default)]
value: u64,
#[serde(default)]
status: StatusWire,
}
impl From<UtxoWire> for Utxo {
fn from(w: UtxoWire) -> Self {
Utxo {
txid: w.txid,
vout: w.vout,
value: w.value,
confirmed: w.status.confirmed,
block_height: w.status.block_height,
}
}
}
#[derive(Debug, Deserialize, Default)]
struct TxOutWire {
#[serde(default)]
value: u64,
#[serde(default)]
scriptpubkey: Option<String>,
#[serde(default)]
scriptpubkey_address: Option<String>,
}
impl From<TxOutWire> for TxOut {
fn from(w: TxOutWire) -> Self {
TxOut {
value: w.value,
scriptpubkey: w.scriptpubkey,
scriptpubkey_address: w.scriptpubkey_address,
}
}
}
#[derive(Debug, Deserialize)]
struct TxWire {
txid: String,
#[serde(default)]
vout: Vec<TxOutWire>,
#[serde(default)]
status: StatusWire,
}
impl From<TxWire> for TxInfo {
fn from(w: TxWire) -> Self {
TxInfo {
txid: w.txid,
vout: w.vout.into_iter().map(TxOut::from).collect(),
confirmed: w.status.confirmed,
block_height: w.status.block_height,
}
}
}
#[async_trait(?Send)]
impl MempoolLookup for MempoolHttpClient {
async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
let url = format!("{}/api/address/{address}/utxo", self.base);
let body = self.get_text(&url).await?;
let wire: Vec<UtxoWire> = serde_json::from_str(&body)
.map_err(|e| PaymentError::InvalidState(format!("malformed utxo JSON: {e}")))?;
Ok(wire.into_iter().map(Utxo::from).collect())
}
async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
let url = format!("{}/api/tx/{txid}", self.base);
let body = self.get_text(&url).await?;
let wire: TxWire = serde_json::from_str(&body)
.map_err(|e| PaymentError::InvalidState(format!("malformed tx JSON: {e}")))?;
Ok(TxInfo::from(wire))
}
}
#[async_trait(?Send)]
impl MempoolBroadcast for MempoolHttpClient {
async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
let url = format!("{}/api/tx", self.base);
self.post_text(&url, raw_hex).await
}
}
#[derive(Clone)]
pub struct MempoolBlockAnchorer<M: MempoolLookup + MempoolBroadcast + Send + Sync> {
lookup: M,
storage: Option<std::sync::Arc<dyn solid_pod_rs::storage::Storage>>,
}
impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> MempoolBlockAnchorer<M> {
pub fn new(lookup: M) -> Self {
Self { lookup, storage: None }
}
pub fn with_storage(
lookup: M,
storage: std::sync::Arc<dyn solid_pod_rs::storage::Storage>,
) -> Self {
Self {
lookup,
storage: Some(storage),
}
}
pub fn lookup(&self) -> &M {
&self.lookup
}
}
#[async_trait(?Send)]
impl<M: MempoolLookup + MempoolBroadcast + Send + Sync> BlockAnchorer for MempoolBlockAnchorer<M> {
async fn anchor(
&self,
ticker: &str,
state_hash: &str,
network: &str,
) -> Result<BlockTrailAnchor, ProvenanceError> {
use crate::trail_store::{load_trail, save_trail};
use solid_pod_rs::bitcoin_tx::DEFAULT_FEE_SATS;
let storage = self.storage.as_ref().ok_or_else(|| {
ProvenanceError::Anchor(
"anchor() requires storage; construct with MempoolBlockAnchorer::with_storage"
.into(),
)
})?;
let mut stored = load_trail(storage, ticker)
.await
.map_err(|e| ProvenanceError::Anchor(format!("load trail {ticker}: {e}")))?
.ok_or_else(|| ProvenanceError::Anchor(format!("trail {ticker} not minted on this pod")))?;
if stored.network != network {
return Err(ProvenanceError::Anchor(format!(
"network mismatch: trail is {}, requested {network}",
stored.network
)));
}
let public = stored.to_public();
let update = anchor_state(&public, &stored.privkey, state_hash, DEFAULT_FEE_SATS, &self.lookup)
.await
.map_err(|e| ProvenanceError::Anchor(format!("build anchoring tx: {e}")))?;
let txid = self
.lookup
.broadcast_tx(&update.tx.raw_hex)
.await
.map_err(|e| ProvenanceError::Anchor(format!("broadcast anchoring tx: {e}")))?;
let mut appended = update.trail.clone();
appended.current_txid = txid.clone();
stored.merge_public(&appended);
stored.current_txid = txid.clone();
stored.current_vout = 0;
save_trail(storage, &stored)
.await
.map_err(|e| ProvenanceError::Anchor(format!("save trail: {e}")))?;
Ok(BlockTrailAnchor {
ticker: ticker.to_string(),
state_hash: state_hash.to_string(),
txid,
vout: 0,
address: update.address,
network: network.to_string(),
blockheight: None,
state_strings: appended.state_strings,
pubkey: Some(stored.pubkey_base),
})
}
async fn verify(&self, anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
let Some(pubkey) = anchor.pubkey.as_deref() else {
return Ok(false);
};
if anchor.state_strings.is_empty() {
return Ok(false);
}
let derived = bt_address(pubkey, &anchor.state_strings, &anchor.network)
.map_err(|e| ProvenanceError::Anchor(format!("address re-derivation failed: {e}")))?;
if derived != anchor.address {
return Ok(false);
}
let utxos = self
.lookup
.address_utxos(&derived)
.await
.map_err(|e| ProvenanceError::Anchor(format!("mempool lookup failed: {e}")))?;
Ok(!utxos.is_empty())
}
}
#[cfg(test)]
mod tests {
use super::*;
const UTXO_JSON: &str = include_str!("../tests/fixtures/mempool/address_utxos.json");
const TX_JSON: &str = include_str!("../tests/fixtures/mempool/tx.json");
const EMPTY_UTXO_JSON: &str = "[]";
#[test]
fn utxo_wire_flattens_status() {
let wire: Vec<UtxoWire> = serde_json::from_str(UTXO_JSON).unwrap();
let utxos: Vec<Utxo> = wire.into_iter().map(Utxo::from).collect();
assert_eq!(utxos.len(), 1);
assert_eq!(utxos[0].vout, 0);
assert_eq!(utxos[0].value, 9700);
assert!(utxos[0].confirmed, "status.confirmed must flatten onto Utxo");
assert_eq!(utxos[0].block_height, Some(42_000));
}
#[test]
fn empty_utxo_set_parses_to_empty_vec() {
let wire: Vec<UtxoWire> = serde_json::from_str(EMPTY_UTXO_JSON).unwrap();
assert!(wire.is_empty());
}
#[test]
fn tx_wire_flattens_outputs_and_status() {
let wire: TxWire = serde_json::from_str(TX_JSON).unwrap();
let tx = TxInfo::from(wire);
assert_eq!(tx.vout.len(), 2);
assert_eq!(tx.vout[0].value, 9700);
assert_eq!(
tx.vout[0].scriptpubkey.as_deref(),
Some("5120aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899")
);
assert_eq!(
tx.vout[0].scriptpubkey_address.as_deref(),
Some("tb1pexampleaddress")
);
assert!(tx.confirmed);
assert_eq!(tx.block_height, Some(42_000));
}
#[test]
fn from_env_defaults_to_testnet4() {
let prev = std::env::var(MEMPOOL_URL_ENV).ok();
std::env::remove_var(MEMPOOL_URL_ENV);
let c = MempoolHttpClient::from_env();
assert_eq!(c.base_url(), DEFAULT_MEMPOOL_URL);
if let Some(v) = prev {
std::env::set_var(MEMPOOL_URL_ENV, v);
}
}
#[test]
fn new_trims_trailing_slash() {
let c = MempoolHttpClient::new("https://mempool.space/testnet4/");
assert_eq!(c.base_url(), "https://mempool.space/testnet4");
}
#[ignore = "hits live mempool.space; opt-in only"]
#[tokio::test]
async fn live_address_utxos_smoke() {
let c = MempoolHttpClient::from_env();
let _ = c
.address_utxos("tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c")
.await;
}
use std::collections::HashMap;
#[derive(Clone, Default)]
struct FixtureMempool {
utxos: std::sync::Arc<std::sync::Mutex<HashMap<String, Vec<Utxo>>>>,
txs: std::sync::Arc<std::sync::Mutex<HashMap<String, Vec<TxOut>>>>,
broadcasts: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}
impl FixtureMempool {
fn with_utxo_at(address: &str) -> Self {
let me = Self::default();
me.utxos.lock().unwrap().insert(
address.to_string(),
vec![Utxo {
txid: "ab".repeat(32),
vout: 0,
value: 9700,
confirmed: true,
block_height: Some(42_000),
}],
);
me
}
fn empty() -> Self {
Self::default()
}
fn add_output(&self, txid: &str, vout: u32, spk_hex: &str) {
let mut txs = self.txs.lock().unwrap();
let outs = txs.entry(txid.to_string()).or_default();
while outs.len() <= vout as usize {
outs.push(TxOut { value: 0, scriptpubkey: None, scriptpubkey_address: None });
}
outs[vout as usize] = TxOut {
value: 0,
scriptpubkey: Some(spk_hex.to_string()),
scriptpubkey_address: None,
};
}
}
#[async_trait(?Send)]
impl MempoolLookup for FixtureMempool {
async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
Ok(self.utxos.lock().unwrap().get(address).cloned().unwrap_or_default())
}
async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
Ok(TxInfo {
txid: txid.to_string(),
vout: self.txs.lock().unwrap().get(txid).cloned().unwrap_or_default(),
confirmed: true,
block_height: Some(42_000),
})
}
}
#[async_trait(?Send)]
impl MempoolBroadcast for FixtureMempool {
async fn broadcast_tx(&self, raw_hex: &str) -> Result<String, PaymentError> {
let txid = solid_pod_rs::mrc20::sha256_hex(raw_hex);
self.broadcasts.lock().unwrap().push(raw_hex.to_string());
Ok(txid)
}
}
const ISSUER_PRIVKEY: &str =
"0000000000000000000000000000000000000000000000000000000000000001";
fn issuer_pubkey() -> String {
let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
hex::encode(sk.public_key().to_sec1_bytes())
}
fn consistent_anchor() -> BlockTrailAnchor {
let pubkey = issuer_pubkey();
let state_strings = vec!["{\"seq\":0}".to_string(), "{\"seq\":1}".to_string()];
let address = bt_address(&pubkey, &state_strings, "testnet4").unwrap();
BlockTrailAnchor {
ticker: "PROV".into(),
state_hash: "ff".repeat(32),
txid: "ab".repeat(32),
vout: 0,
address,
network: "testnet4".into(),
blockheight: Some(42_000),
state_strings,
pubkey: Some(pubkey),
}
}
#[tokio::test]
async fn block_anchorer_verify_true_when_utxo_present() {
let anchor = consistent_anchor();
let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&anchor.address));
assert!(anchorer.verify(&anchor).await.unwrap(), "present UTXO ⇒ verify true");
}
#[tokio::test]
async fn block_anchorer_verify_false_when_utxo_absent() {
let anchor = consistent_anchor();
let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
assert!(!anchorer.verify(&anchor).await.unwrap(), "absent UTXO ⇒ verify false");
}
#[tokio::test]
async fn block_anchorer_verify_false_when_address_forged() {
let mut anchor = consistent_anchor();
let real = anchor.address.clone();
anchor.address = "tb1pforged000000000000000000000000000000".into();
let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&real));
assert!(
!anchorer.verify(&anchor).await.unwrap(),
"forged address must not verify even with a real UTXO elsewhere"
);
}
#[tokio::test]
async fn block_anchorer_verify_false_without_pubkey() {
let mut anchor = consistent_anchor();
anchor.pubkey = None;
let anchorer = MempoolBlockAnchorer::new(FixtureMempool::with_utxo_at(&anchor.address));
assert!(!anchorer.verify(&anchor).await.unwrap());
}
#[tokio::test]
async fn block_anchorer_anchor_requires_storage() {
let anchorer = MempoolBlockAnchorer::new(FixtureMempool::empty());
let err = anchorer.anchor("PROV", "deadbeef", "testnet4").await.unwrap_err();
match err {
ProvenanceError::Anchor(m) => assert!(m.contains("with_storage")),
other => panic!("expected Anchor(requires storage), got {other:?}"),
}
}
use crate::trail_store::{load_trail, save_trail, StoredTrail};
use solid_pod_rs::bitcoin_tx::mint_token;
use solid_pod_rs::storage::memory::MemoryBackend;
use solid_pod_rs::storage::Storage;
async fn mint_and_store(
ticker: &str,
) -> (std::sync::Arc<dyn Storage>, FixtureMempool, String) {
let mempool = FixtureMempool::empty();
let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
let sk = k256::SecretKey::from_slice(&hex::decode(ISSUER_PRIVKEY).unwrap()).unwrap();
let compressed = sk.public_key().to_sec1_bytes();
let xonly_hex = hex::encode(&compressed[1..]);
let voucher_txid = "11".repeat(32);
mempool.add_output(&voucher_txid, 0, &format!("5120{xonly_hex}"));
let voucher = solid_pod_rs::bitcoin_tx::TxoVoucher {
txid: voucher_txid,
vout: 0,
amount: 100_000,
privkey: ISSUER_PRIVKEY.to_string(),
};
let mint = mint_token(ticker, None, 1_000, &voucher, "testnet4", 300, &mempool)
.await
.unwrap();
let mint_txid = mempool.broadcast_tx(&mint.tx.raw_hex).await.unwrap();
let mut stored = StoredTrail {
ticker: mint.trail.ticker.clone(),
name: mint.trail.name.clone(),
supply: mint.trail.supply,
privkey: ISSUER_PRIVKEY.to_string(),
pubkey_base: mint.trail.pubkey_base.clone(),
states: mint.trail.states.clone(),
state_strings: mint.trail.state_strings.clone(),
current_txid: mint_txid.clone(),
current_vout: 0,
current_amount: mint.trail.current_amount,
network: mint.trail.network.clone(),
date_created: "2026-06-13T00:00:00Z".into(),
};
stored.current_txid = mint_txid.clone();
save_trail(&storage, &stored).await.unwrap();
let genesis_xonly = {
let chained = solid_pod_rs::mrc20::bt_derive_chained_pubkey(
&issuer_pubkey(),
&[mint.state_jcs.clone()],
)
.unwrap();
hex::encode(&chained[1..])
};
mempool.add_output(&mint_txid, 0, &format!("5120{genesis_xonly}"));
(storage, mempool, ticker.to_string())
}
#[tokio::test]
async fn block_anchorer_anchor_round_trip_and_self_verifies() {
let (storage, mempool, ticker) = mint_and_store("ANCH").await;
let anchorer = MempoolBlockAnchorer::with_storage(mempool.clone(), storage.clone());
let commit_sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9001122334";
let anchor = anchorer
.anchor(&ticker, commit_sha, "testnet4")
.await
.expect("anchor() must build + broadcast + persist");
assert_eq!(anchor.ticker, "ANCH");
assert_eq!(anchor.state_hash, commit_sha);
assert_eq!(anchor.vout, 0);
assert!(anchor.blockheight.is_none());
assert_eq!(anchor.network, "testnet4");
assert!(anchor.pubkey.is_some());
assert_eq!(anchor.state_strings.len(), 2);
let derived = bt_address(
anchor.pubkey.as_deref().unwrap(),
&anchor.state_strings,
"testnet4",
)
.unwrap();
assert_eq!(anchor.address, derived);
let reloaded = load_trail(&storage, "ANCH").await.unwrap().unwrap();
assert_eq!(reloaded.states.len(), 2);
assert_eq!(reloaded.current_txid, anchor.txid);
assert_eq!(reloaded.states[1].anchor.as_deref(), Some(commit_sha));
mempool.utxos.lock().unwrap().insert(
anchor.address.clone(),
vec![Utxo {
txid: anchor.txid.clone(),
vout: 0,
value: 9_400,
confirmed: false,
block_height: None,
}],
);
assert!(
anchorer.verify(&anchor).await.unwrap(),
"the anchor we just produced must verify against its own UTXO"
);
}
#[tokio::test]
async fn block_anchorer_anchor_rejects_unminted_ticker() {
let storage: std::sync::Arc<dyn Storage> = std::sync::Arc::new(MemoryBackend::new());
let anchorer = MempoolBlockAnchorer::with_storage(FixtureMempool::empty(), storage);
let err = anchorer.anchor("GHOST", "deadbeef", "testnet4").await.unwrap_err();
match err {
ProvenanceError::Anchor(m) => assert!(m.contains("not minted")),
other => panic!("expected not-minted error, got {other:?}"),
}
}
}