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, Copy, PartialEq, Eq)]
pub enum MempoolConfigSource {
Explicit,
Default,
}
impl MempoolConfigSource {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Explicit => "explicit",
Self::Default => "default",
}
}
}
impl std::fmt::Display for MempoolConfigSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BitcoinNetwork {
Mainnet,
Testnet3,
Testnet4,
Signet,
Regtest,
Unknown,
}
impl BitcoinNetwork {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Mainnet => "mainnet",
Self::Testnet3 => "testnet3",
Self::Testnet4 => "testnet4",
Self::Signet => "signet",
Self::Regtest => "regtest",
Self::Unknown => "unknown",
}
}
}
impl std::fmt::Display for BitcoinNetwork {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[must_use]
pub fn infer_network(base_url: &str) -> BitcoinNetwork {
let trimmed = base_url.trim().trim_end_matches('/');
let after_scheme = match trimmed.find("://") {
Some(i) => &trimmed[i + 3..],
None => trimmed,
};
let (authority, path) = match after_scheme.find('/') {
Some(i) => (&after_scheme[..i], &after_scheme[i + 1..]),
None => (after_scheme, ""),
};
let path = path
.split(['?', '#'])
.next()
.unwrap_or("")
.trim_end_matches('/');
let last_segment = path.rsplit('/').find(|seg| !seg.is_empty()).unwrap_or("");
match last_segment.to_ascii_lowercase().as_str() {
"testnet4" => return BitcoinNetwork::Testnet4,
"testnet" | "testnet3" => return BitcoinNetwork::Testnet3,
"signet" => return BitcoinNetwork::Signet,
"regtest" => return BitcoinNetwork::Regtest,
"mainnet" | "bitcoin" => return BitcoinNetwork::Mainnet,
_ => {}
}
let host = authority.rsplit('@').next().unwrap_or(authority);
let host = if let Some(rest) = host.strip_prefix('[') {
rest.split(']').next().unwrap_or(rest)
} else {
host.split(':').next().unwrap_or(host)
};
let host_lower = host.to_ascii_lowercase();
let is_loopback = host_lower == "localhost"
|| host_lower == "::1"
|| host_lower.starts_with("127.")
|| host_lower.ends_with(".localhost");
if is_loopback {
return BitcoinNetwork::Regtest;
}
if path.is_empty() && (host_lower == "mempool.space" || host_lower.ends_with(".mempool.space"))
{
return BitcoinNetwork::Mainnet;
}
BitcoinNetwork::Unknown
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MempoolSelection {
pub base_url: String,
pub network: BitcoinNetwork,
pub source: MempoolConfigSource,
}
impl MempoolSelection {
#[must_use]
pub fn to_manifest_json(&self) -> serde_json::Value {
serde_json::json!({
"base_url": self.base_url,
"network": self.network.as_str(),
"source": self.source.as_str(),
})
}
}
#[must_use]
pub fn select_mempool_endpoint(configured: Option<&str>) -> MempoolSelection {
let (raw, source) = match configured.map(str::trim).filter(|v| !v.is_empty()) {
Some(v) => (v, MempoolConfigSource::Explicit),
None => (DEFAULT_MEMPOOL_URL, MempoolConfigSource::Default),
};
let base_url = raw.trim_end_matches('/').to_string();
let network = infer_network(&base_url);
MempoolSelection {
base_url,
network,
source,
}
}
#[must_use]
pub fn select_mempool_endpoint_from_env() -> MempoolSelection {
let configured = std::env::var(MEMPOOL_URL_ENV).ok();
select_mempool_endpoint(configured.as_deref())
}
pub fn log_mempool_selection(sel: &MempoolSelection) {
tracing::info!(
target: "solid_pod_rs_server::mempool",
base_url = %sel.base_url,
network = sel.network.as_str(),
source = sel.source.as_str(),
"mempool endpoint selected"
);
if sel.network == BitcoinNetwork::Unknown || sel.source == MempoolConfigSource::Default {
tracing::warn!(
target: "solid_pod_rs_server::mempool",
base_url = %sel.base_url,
network = sel.network.as_str(),
source = sel.source.as_str(),
env_var = MEMPOOL_URL_ENV,
"pod is using an unverified or defaulted Bitcoin endpoint; anchors may be \
written to or verified against an unintended chain — set {} to pin the \
explorer, and therefore the network, explicitly",
MEMPOOL_URL_ENV
);
}
}
pub fn log_mempool_selection_once(sel: &MempoolSelection) {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| log_mempool_selection(sel));
}
#[derive(Debug, Clone)]
pub struct MempoolHttpClient {
client: reqwest::Client,
base: String,
selection: MempoolSelection,
}
impl MempoolHttpClient {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
let raw = base_url.into();
let base = raw.trim_end_matches('/').to_string();
let selection = MempoolSelection {
network: infer_network(&base),
base_url: base,
source: MempoolConfigSource::Explicit,
};
Self::from_selection(selection)
}
#[must_use]
pub fn from_selection(selection: MempoolSelection) -> Self {
Self {
client: reqwest::Client::new(),
base: selection.base_url.clone(),
selection,
}
}
#[must_use]
pub fn from_env() -> Self {
let selection = select_mempool_endpoint_from_env();
log_mempool_selection_once(&selection);
Self::from_selection(selection)
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base
}
#[must_use]
pub fn selection(&self) -> &MempoolSelection {
&self.selection
}
pub async fn transaction_exists(&self, txid: &str) -> Result<bool, PaymentError> {
let url = format!("{}/api/tx/{txid}", self.base);
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| PaymentError::InvalidState(format!("mempool request failed: {e}")))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(false);
}
if !resp.status().is_success() {
return Err(PaymentError::InvalidState(format!(
"mempool API error: {} for {url}",
resp.status().as_u16()
)));
}
Ok(true)
}
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(),
std::slice::from_ref(&mint.state_jcs),
)
.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:?}"),
}
}
}