bsv-tracker 0.4.1

A transaction evidence state machine with host supplied headers, proofs, hints and time
Documentation
use crate::{Height, Timestamp, TxId};
use serde::Deserialize;
use std::fmt;

/// A broadcaster's claim reduced to the Lean hint vocabulary.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HintStatus {
    /// The broadcaster accepted the transaction.
    Accepted,
    /// The broadcaster reports the transaction seen on the network.
    Seen,
    /// The broadcaster claims a block at a height; never inclusion evidence.
    Mined {
        /// The height the broadcaster names.
        height: Height,
    },
    /// The broadcaster reports the transaction mined in a block that went stale.
    StaleBlock,
    /// The broadcaster reports a rejection.
    Rejected {
        /// The broadcaster's reason, or its status word when it gave none.
        reason: String,
    },
    /// The broadcaster reports a double spend.
    DoubleSpend {
        /// The competing txids the broadcaster names.
        competitors: Vec<TxId>,
    },
    /// The broadcaster reports the transaction in its orphan mempool.
    OrphanMempool,
    /// A status the vocabulary does not reduce to any other hint.
    Unknown,
}
/// An observed hint, carrying its source and host supplied time.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hint {
    /// The host's name for the broadcaster or feed that supplied the hint.
    pub source: String,
    /// The claim, in the Lean hint vocabulary.
    pub status: HintStatus,
    /// When the host observed the hint, on its own clock.
    pub observed: Timestamp,
    /// The original broadcaster metadata, when reduced through `Verdict`.
    /// This retains the raw status, reason and named competitors for re-asks.
    pub verdict: Option<Verdict>,
}
impl Hint {
    /// A reduced hint, for hosts whose source already supplies the Lean vocabulary.
    pub fn new(source: impl Into<String>, status: HintStatus, observed: Timestamp) -> Self {
        Self {
            source: source.into(),
            status,
            observed,
            verdict: None,
        }
    }
}
/// Raw broadcaster metadata. HTTP acceptance is not chain evidence.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Verdict {
    /// The `txStatus` word, trimmed and upper-cased by [`Verdict::from_arc_body`].
    pub status: String,
    /// The raw `blockHeight`, when present.
    pub height: Option<Height>,
    /// The raw `extraInfo`, when present: text, never a status.
    pub reason: Option<String>,
    /// The `competingTxs`, lowercased and checked for shape and uniqueness.
    pub competitors: Vec<TxId>,
}
impl Verdict {
    /// Read an ARC body as metadata, checking its echoed txid and the bounded
    /// status vocabulary at bsv-rs@7bc623c. HTTP status is never consulted.
    ///
    /// The word is trimmed before the vocabulary check and kept trimmed and
    /// upper-cased, so `" SEEN_ON_NETWORK "` reads as `SEEN_ON_NETWORK`
    /// (0.4.1; bsv-stack-lean #169, the spaced body of LOW's door test
    /// `tracker_vocabulary_differs_from_the_door_exactly_here` in
    /// bsv-overlay-cloudflare `crates/overlay-cloudflare/src/door_words.rs`).
    ///
    /// # Errors
    ///
    /// [`VerdictError::Shape`] when the body does not decode,
    /// [`VerdictError::TxidMismatch`] when it echoes another or a malformed
    /// txid, [`VerdictError::InvalidMetadata`] when its text or competitors
    /// break the bounds, and [`VerdictError::UnknownStatus`] for a status word
    /// outside the vocabulary.
    pub fn from_arc_body(expected_txid: &str, bytes: &[u8]) -> Result<Self, VerdictError> {
        #[derive(Deserialize)]
        #[serde(rename_all = "camelCase")]
        struct Body {
            txid: Option<String>,
            tx_status: String,
            block_height: Option<Height>,
            extra_info: Option<String>,
            competing_txs: Option<Vec<TxId>>,
        }
        let body: Body =
            serde_json::from_slice(bytes).map_err(|e| VerdictError::Shape(e.to_string()))?;
        if body
            .txid
            .as_deref()
            .is_some_and(|txid| !is_txid(txid) || !txid.eq_ignore_ascii_case(expected_txid))
        {
            return Err(VerdictError::TxidMismatch);
        }
        if !bounded_text(&body.tx_status, 128, false)
            || body
                .extra_info
                .as_deref()
                .is_some_and(|info| !bounded_text(info, 8_192, true))
        {
            return Err(VerdictError::InvalidMetadata);
        }
        let competitors: Vec<_> = body
            .competing_txs
            .unwrap_or_default()
            .into_iter()
            .map(|id| id.to_ascii_lowercase())
            .collect();
        if competitors.len() > 256
            || competitors.iter().any(|id| !is_txid(id))
            || competitors
                .iter()
                .collect::<std::collections::BTreeSet<_>>()
                .len()
                != competitors.len()
        {
            return Err(VerdictError::InvalidMetadata);
        }
        let status = body.tx_status.trim().to_ascii_uppercase();
        let known = matches!(
            status.as_str(),
            "SUCCESS"
                | "UNKNOWN"
                | "RECEIVED"
                | "QUEUED"
                | "STORED"
                | "ANNOUNCED_TO_NETWORK"
                | "REQUESTED_BY_NETWORK"
                | "SENT_TO_NETWORK"
                | "ACCEPTED_BY_NETWORK"
                | "SEEN_ON_NETWORK"
                | "SEEN_MULTIPLE_NODES"
                | "MINED"
                | "CONFIRMED"
                | "IMMUTABLE"
                | "MINED_IN_STALE_BLOCK"
                | "REJECTED"
                | "INVALID"
                | "MALFORMED"
                | "DOUBLE_SPEND_ATTEMPTED"
                | "UTXO_SPENT"
                | "SEEN_IN_ORPHAN_MEMPOOL"
                | "PENDING_RETRY"
                | "STUMP_PROCESSING"
        );
        if !known {
            return Err(VerdictError::UnknownStatus(body.tx_status));
        }
        Ok(Self {
            status,
            height: body.block_height,
            reason: body.extra_info,
            competitors,
        })
    }
    /// Interpret a status as a hint; no result is a node verdict or a proof.
    ///
    /// Only the status word moves the status (0.4.1; bsv-stack-lean #169,
    /// LOW's door test `tracker_vocabulary_differs_from_the_door_exactly_here`
    /// in bsv-overlay-cloudflare `crates/overlay-cloudflare/src/door_words.rs`):
    /// `extraInfo` is text and stays on `reason`, so an `ORPHAN` or
    /// `reorg_unmined` there no longer overrides the word; only
    /// `SEEN_IN_ORPHAN_MEMPOOL` is [`HintStatus::OrphanMempool`] and only the
    /// stale-block words are [`HintStatus::StaleBlock`]. `PENDING_RETRY` and
    /// `STUMP_PROCESSING`, a broadcaster still working on it, are
    /// [`HintStatus::Accepted`]. A mined word with no height stays
    /// [`HintStatus::Unknown`], and named competitors still turn an accepting
    /// word into [`HintStatus::DoubleSpend`].
    pub fn into_hint(self, source: impl Into<String>, observed: Timestamp) -> Hint {
        let status = match self.status.to_ascii_uppercase().as_str() {
            "SUCCESS"
            | "SENT_TO_NETWORK"
            | "RECEIVED"
            | "QUEUED"
            | "STORED"
            | "ANNOUNCED_TO_NETWORK"
            | "REQUESTED_BY_NETWORK"
            | "ACCEPTED_BY_NETWORK"
            | "PENDING_RETRY"
            | "STUMP_PROCESSING" => HintStatus::Accepted,
            "SEEN_ON_NETWORK" | "SEEN_MULTIPLE_NODES" => HintStatus::Seen,
            "MINED" | "CONFIRMED" | "IMMUTABLE" => self
                .height
                .map_or(HintStatus::Unknown, |height| HintStatus::Mined { height }),
            "MINED_IN_STALE_BLOCK" | "REORG_UNMINED" | "REORG_UNCONFIRMED" => {
                HintStatus::StaleBlock
            }
            "SEEN_IN_ORPHAN_MEMPOOL" => HintStatus::OrphanMempool,
            "DOUBLE_SPEND_ATTEMPTED" | "UTXO_SPENT" => HintStatus::DoubleSpend {
                competitors: self.competitors.clone(),
            },
            "REJECTED" | "INVALID" | "MALFORMED" => HintStatus::Rejected {
                reason: self
                    .reason
                    .clone()
                    .filter(|s| !s.is_empty())
                    .unwrap_or_else(|| self.status.clone()),
            },
            _ => HintStatus::Unknown,
        };
        let status = if !self.competitors.is_empty()
            && matches!(status, HintStatus::Accepted | HintStatus::Seen)
        {
            // A named competitor cannot disappear behind an accepting word.
            HintStatus::DoubleSpend {
                competitors: self.competitors.clone(),
            }
        } else {
            status
        };
        Hint {
            source: source.into(),
            status,
            observed,
            verdict: Some(self),
        }
    }
}

/// An invalid broadcaster body is a reported fault, never chain evidence.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VerdictError {
    /// The expected fields cannot be decoded.
    Shape(String),
    /// The body names another transaction or a malformed identifier.
    TxidMismatch,
    /// Text or competing identifiers violate the pinned metadata bounds.
    InvalidMetadata,
    /// The sender uses a word this adapter does not know.
    UnknownStatus(String),
}
impl fmt::Display for VerdictError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{self:?}")
    }
}
impl std::error::Error for VerdictError {}

fn is_txid(value: &str) -> bool {
    value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
}
fn bounded_text(value: &str, max: usize, empty: bool) -> bool {
    (empty || !value.is_empty())
        && value.len() <= max
        && !value
            .chars()
            .any(|c| matches!(c as u32, 0..=31 | 127..=159))
}